mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9620dab758 | ||
|
|
e318b651bb | ||
|
|
3ac815eee4 | ||
|
|
10cc17bb66 | ||
|
|
ca09d6adb8 | ||
|
|
af058609dd | ||
|
|
a4ab5b005e | ||
|
|
672a68cd1b | ||
|
|
24d7e4c415 | ||
|
|
1127d4baf7 | ||
|
|
dc550ab735 | ||
|
|
5380cf0660 | ||
|
|
7cc6b64ce2 | ||
|
|
b5ff4ec22b | ||
|
|
302dd6c344 | ||
|
|
62fb8babad | ||
|
|
fe44576b06 | ||
|
|
355eb8bffd | ||
|
|
7d197bf021 | ||
|
|
cf0c324d80 | ||
|
|
5a3bbe82a0 | ||
|
|
b6bd6497b2 | ||
|
|
0eeb486d88 | ||
|
|
1adeb6f7e1 | ||
|
|
bc0d6302d3 | ||
|
|
1341b4a08a | ||
|
|
c6b7f0e178 | ||
|
|
d3592457cf | ||
|
|
024850bf48 | ||
|
|
96d16d048c | ||
|
|
1b44d2d798 | ||
|
|
e4725233dd | ||
|
|
913e4af133 | ||
|
|
07d164ac74 | ||
|
|
01a7ae1930 | ||
|
|
c52a6c32b7 | ||
|
|
f21df41506 | ||
|
|
ff8a6a4cff | ||
|
|
a2d9ed7008 | ||
|
|
3994c9bd2e | ||
|
|
4e4a246a51 | ||
|
|
ebd0ba4add | ||
|
|
42785ce6be | ||
|
|
db205919c8 | ||
|
|
f452099926 | ||
|
|
03b74b1150 | ||
|
|
fbaeb0cc57 | ||
|
|
5f557a5973 | ||
|
|
86edc42b98 | ||
|
|
68f9bf35a1 | ||
|
|
e314aa8546 | ||
|
|
1b2010f939 | ||
|
|
f25eafcc48 | ||
|
|
473c12ad8f | ||
|
|
89b8157eb9 | ||
|
|
304c1de3bc | ||
|
|
6156da4b67 | ||
|
|
ae7c3ec308 | ||
|
|
e7f0aef43d | ||
|
|
97c6a26f61 | ||
|
|
5f9701ccff | ||
|
|
043dc11251 |
No files matched your search
@@ -0,0 +1,59 @@
|
||||
name: Bug report
|
||||
description: Something in Gearslip broke or behaves wrong.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting. Check the [open issues](https://github.com/Seb3thehacker/gearslip/issues) first, in case someone has already reported it.
|
||||
|
||||
- type: input
|
||||
id: car
|
||||
attributes:
|
||||
label: Car
|
||||
description: Make, model, and year.
|
||||
placeholder: 2019 Honda Civic
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: head-unit
|
||||
attributes:
|
||||
label: Head unit
|
||||
options:
|
||||
- The one the car came with
|
||||
- A factory unit from another car
|
||||
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||
- A homemade or open-source unit, such as LIVI
|
||||
- I don't know
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: connection
|
||||
attributes:
|
||||
label: Connection
|
||||
options:
|
||||
- USB cable
|
||||
- Wireless dongle
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What you did, what you expected, and what happened instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Log
|
||||
description: |
|
||||
On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||
The log records your phone, its Android version, the Gearslip version, and why the connection ended.
|
||||
placeholder: Drop the log file here.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Car report
|
||||
description: Tell us whether Gearslip works in your car, so others know before they install.
|
||||
labels: ["car report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Reports from working cars help as much as reports from broken ones.
|
||||
|
||||
- type: input
|
||||
id: car
|
||||
attributes:
|
||||
label: Car
|
||||
description: Make, model, and year.
|
||||
placeholder: 2019 Honda Civic
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: head-unit
|
||||
attributes:
|
||||
label: Head unit
|
||||
options:
|
||||
- The one the car came with
|
||||
- A factory unit from another car
|
||||
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||
- A homemade or open-source unit, such as LIVI
|
||||
- I don't know
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: connection
|
||||
attributes:
|
||||
label: Connection
|
||||
options:
|
||||
- USB cable
|
||||
- Wireless dongle
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: result
|
||||
attributes:
|
||||
label: Does it work?
|
||||
options:
|
||||
- Yes, everything I tried works
|
||||
- Partly. It connects, but something is wrong
|
||||
- No, it never connects
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
description: What you tried, and anything that didn't work.
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Log
|
||||
description: |
|
||||
Needed if the car doesn't work. On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||
placeholder: Drop the log file here.
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Feature request
|
||||
description: Suggest something Gearslip should do.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What do you want to do?
|
||||
description: Describe the problem, not the solution. "I can't see my next turn while music plays" helps more than "add a turn widget."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: How should it work?
|
||||
description: Your idea for a fix, if you have one.
|
||||
|
||||
- type: textarea
|
||||
id: apps
|
||||
attributes:
|
||||
label: Apps involved
|
||||
description: Any apps this touches, such as a map or music app.
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Write signing key and properties
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > gearslip-release.jks
|
||||
cat > keystore.properties <<EOF
|
||||
storeFile=gearslip-release.jks
|
||||
storePassword=${{ secrets.KEYSTORE_STORE_PASSWORD }}
|
||||
keyAlias=${{ secrets.KEYSTORE_KEY_ALIAS }}
|
||||
keyPassword=${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
||||
EOF
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew :gearslip:assembleRelease
|
||||
|
||||
- name: Locate APK
|
||||
id: apk
|
||||
run: |
|
||||
# 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"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ steps.apk.outputs.path }}
|
||||
generate_release_notes: true
|
||||
+19
-12
@@ -6,20 +6,27 @@
|
||||
/build
|
||||
local.properties
|
||||
|
||||
# Reference checkouts only - never referenced by the build; safe to delete entirely.
|
||||
/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.
|
||||
# Keys
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
|
||||
# Local investigation script; not meant for the public repo.
|
||||
/docs/cert-sweep.sh
|
||||
archive/
|
||||
# Native build cache.
|
||||
.cxx/
|
||||
|
||||
# Testing
|
||||
/cloned-repos
|
||||
docs-internal/
|
||||
|
||||
# Internal
|
||||
*.pdf
|
||||
todo.md
|
||||
logs/
|
||||
|
||||
# For AI code
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
*.pdf
|
||||
|
||||
# Other
|
||||
archive/
|
||||
.wrangler/
|
||||
@@ -0,0 +1,51 @@
|
||||
# Contributing to Gearslip
|
||||
|
||||
Thanks for helping. Bug reports from real cars are the most useful thing you can send, so
|
||||
start there if you aren't sure where to begin.
|
||||
|
||||
## Reporting a bug
|
||||
|
||||
Open an issue on [GitHub](https://github.com/Seb3thehacker/gearslip/issues) and include:
|
||||
|
||||
- your car's make, model, and year, and whether the head unit is the original one; and
|
||||
- the log from the drive. Open Logs on the phone app's home screen and tap Share log.
|
||||
|
||||
The log records your phone, its Android version, the Gearslip version, the head unit, and
|
||||
why the connection ended. Most car bugs can't be fixed without it. Head units often report
|
||||
only a part number, so the log can't name your car for you.
|
||||
|
||||
## Building
|
||||
|
||||
You need Android Studio, or the Android SDK with JDK 21. Gradle downloads the NDK, CMake,
|
||||
and the speech library on the first build.
|
||||
|
||||
```
|
||||
./gradlew :gearslip:assembleDebug
|
||||
```
|
||||
|
||||
The APK lands at `build/Gearslip-debug.apk`. It installs as Gearslip Dev, next to the
|
||||
regular app.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Work happens on the `experimental` branch, which merges into `main` at each release. Base your
|
||||
branch on `experimental`, and open your pull request against it.
|
||||
|
||||
- Keep each pull request to one change, and explain the problem it solves.
|
||||
- Say how you tested it: which car or head unit, which phone, and which apps.
|
||||
- Code written with AI tools is welcome. Read it, test it, and stand behind it as you would
|
||||
your own.
|
||||
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
|
||||
permissions and on-device consent dialogs.
|
||||
|
||||
## Writing
|
||||
|
||||
Text that users read, such as screens, release notes, and docs, follows Strunk's
|
||||
[*The Elements of Style*](https://www.gutenberg.org/ebooks/37134). In short: use the active
|
||||
voice, state things positively, cut needless words, and use the serial comma.
|
||||
|
||||
## License
|
||||
|
||||
Gearslip is licensed under AGPL-3.0, and your contributions are too. The author also sells a
|
||||
commercial license, described in [NOTICE.md](NOTICE.md). By opening a pull request, you
|
||||
agree that your contribution may be offered under that license as well.
|
||||
@@ -1,16 +1,9 @@
|
||||
# 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
|
||||
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.
|
||||
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.
|
||||
|
||||
## Commercial licensing
|
||||
|
||||
If the AGPL's terms don't work for you — for example, you want to use Gearslip privately
|
||||
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.
|
||||
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.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Gearslip
|
||||
|
||||
Bring any app to your car screen.
|
||||
An Android Auto alternative built for everyone.
|
||||
|
||||
## What Gearslip is
|
||||
|
||||
@@ -13,31 +13,87 @@ 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
|
||||
mode.
|
||||
|
||||
The author reverse-engineered the protocol for interoperability, so that a client other
|
||||
than Google's own can talk to a car head unit.
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Apps sorted by kind. A check marks the ones that work. | Vela Maps navigating, with the player beside it. |
|
||||
|  |  |
|
||||
| ViVi Music in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||
|  |  |
|
||||
| The weather, your next event, and what you missed. | Browse the web while parked. |
|
||||
|
||||
## Features
|
||||
|
||||
- **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, OsmAnd, and Flitsmeister work today. Vela runs, with rough
|
||||
edges.
|
||||
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||
play the best match.
|
||||
- **The web.** A browser template with its own on-screen keyboard, for anything that
|
||||
doesn't ship a car interface of its own.
|
||||
- **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. Music pauses while
|
||||
Gearslip speaks or listens. Speech runs on the phone, so it needs no Google account.
|
||||
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||
The rest wait on the dashboard.
|
||||
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
|
||||
messaging, and the rest. Press and hold an app to move it.
|
||||
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||
|
||||
## Status
|
||||
|
||||
- **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
|
||||
Library defines, and it draws its own keyboard for search and sign-in. It has been
|
||||
tested against several real navigation and media apps.
|
||||
- **Media playback works.** Gearslip lets you browse and control car-enabled media apps
|
||||
from the car screen, with lyrics and playback state, over the same host connection.
|
||||
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
||||
It has been tested against several real navigation and media apps.
|
||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||
used in homemade car dashboards.
|
||||
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
||||
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||
Gearslip's own player.
|
||||
- **Gearslip works best with cars from before 2020.** Some newer head units reject its
|
||||
certificate.
|
||||
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||
car to hear calls through its speakers.
|
||||
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
|
||||
requires a screen-share style consent dialog and indicator for this, even though
|
||||
Gearslip never reads the screen; that grant is the only way a non-privileged app can
|
||||
tap another app's audio. The consent normally repeats every drive; the app's own setup
|
||||
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.
|
||||
shows a screen-share consent dialog for this, even though Gearslip never reads the
|
||||
screen; it is the only way an ordinary app can capture another app's audio. The dialog
|
||||
appears once per drive.
|
||||
- **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
|
||||
above, for audio, are the sole exception: both are optional, and both only remove a
|
||||
dialog that the driver would otherwise see once per drive.
|
||||
Android permission or an on-device consent dialog. The setup guide offers two optional
|
||||
adb commands: one removes the audio dialog for good, and the other keeps notifications
|
||||
visible while audio plays.
|
||||
|
||||
## Usage notes
|
||||
|
||||
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
||||
turn them on, in setup or in Settings.
|
||||
|
||||
- **When they go out.** When you open Gearslip, at most once a day, and after each drive.
|
||||
Nothing runs in the background.
|
||||
- **What they carry.** The Gearslip version, plus whatever you tick: the Android version
|
||||
and whether the phone runs GrapheneOS, the phone model, whether a car connected that
|
||||
day, and each car's head unit, model, year, screen size, and connection result.
|
||||
Settings shows the exact note.
|
||||
- **What they leave out.** Your name, accounts, location, contacts, messages, and
|
||||
anything you type or play.
|
||||
- **How phones are counted.** A random number counts each phone once. The server keeps
|
||||
only a salted hash of it.
|
||||
- **Where they go.** To a Cloudflare Worker, whose code is in
|
||||
[stats-worker/](stats-worker/). Cloudflare sees your IP address in delivering a note,
|
||||
as it would for any website; the Worker doesn't store it.
|
||||
|
||||
Turn the notes off, and they stop at once.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -57,6 +113,12 @@ keystore. Without them, the debug build still works.
|
||||
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
|
||||
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
|
||||
|
||||
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
|
||||
|
||||
+65
-9
@@ -1,11 +1,12 @@
|
||||
# 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 |
|
||||
| --- | --- | --- |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
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`
|
||||
returns.
|
||||
|
||||
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`, and the library's own
|
||||
`HostValidator` accepts any host holding it. If you build your validator with
|
||||
`HostValidator.Builder` and no allowlist, Gearslip already passes, and you need nothing below.
|
||||
Add an allowlist entry when you wrote a custom validator, or when you want to name Gearslip
|
||||
explicitly:
|
||||
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`. Car App Library 1.8.0-beta01
|
||||
and earlier, and 1.9.0-alpha01, accept any host holding it, so Gearslip passes the default
|
||||
validator. From 1.8.0-rc01 and 1.9.0-alpha02 the library checks that permission only on
|
||||
Android Automotive, so on a phone Gearslip passes only when your allowlist names it. Add this
|
||||
entry:
|
||||
|
||||
```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
|
||||
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
|
||||
`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
|
||||
|
||||
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,
|
||||
"batteryPercent": 82,
|
||||
"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
|
||||
|
||||
- [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
|
||||
reference for templated apps.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -30,10 +30,14 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 35
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 3
|
||||
versionName = "0.0.03-pre"
|
||||
versionCode = 8
|
||||
versionName = "0.2.00-alpha"
|
||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||
buildConfigField("String", "STATS_URL", "\"${project.findProperty("gearslipStatsUrl") ?: ""}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -53,6 +57,18 @@ android {
|
||||
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
|
||||
@@ -65,6 +81,23 @@ android {
|
||||
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 {
|
||||
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
|
||||
unitTests.isReturnDefaultValues = true
|
||||
@@ -104,6 +137,7 @@ dependencies {
|
||||
// their Bundleables deserialize into.
|
||||
implementation(libs.androidx.car.app)
|
||||
implementation(libs.androidx.car.app.projected)
|
||||
implementation(libs.androidx.media)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -112,3 +146,4 @@ tasks.withType<KotlinJvmCompile>().configureEach {
|
||||
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>
|
||||
@@ -12,6 +12,7 @@
|
||||
-->
|
||||
<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_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
@@ -39,6 +40,21 @@
|
||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!--
|
||||
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
||||
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
|
||||
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
||||
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
||||
and spam app too - an on-device consent, not a permission declared here.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
|
||||
<!-- Placing a call from the car screen's dial pad or contacts list needs this. -->
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
|
||||
<!--
|
||||
Declared, not just requested. Templated apps decide whether to talk to a host by asking
|
||||
whether it holds this permission (HostValidator.isValidHost checks it before falling back
|
||||
@@ -54,6 +70,12 @@
|
||||
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
|
||||
|
||||
<queries>
|
||||
<!-- Named directly: current builds have no launcher-category entry point for the MAIN/
|
||||
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
||||
whether the package is installed at all. -->
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<!-- Every GrapheneOS install has this; the opt-in usage note uses it to say "GrapheneOS". -->
|
||||
<package android:name="app.grapheneos.setupwizard" />
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -66,13 +88,21 @@
|
||||
<intent>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</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>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="Gearslip"
|
||||
android:label="${appLabel}"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Gearslip">
|
||||
|
||||
@@ -95,6 +125,7 @@
|
||||
android:name=".GearslipActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
||||
|
||||
<intent-filter>
|
||||
@@ -102,6 +133,18 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
|
||||
</intent-filter>
|
||||
@@ -109,7 +152,12 @@
|
||||
<meta-data
|
||||
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
|
||||
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
|
||||
android:name=".mirror.MirrorService"
|
||||
@@ -121,11 +169,14 @@
|
||||
android:exported="false"
|
||||
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
|
||||
android:name=".audio.AudioCaptureService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
android:foregroundServiceType="mediaProjection|microphone" />
|
||||
|
||||
<!--
|
||||
Touch on the car screen is replayed onto the phone through this service.
|
||||
@@ -158,5 +209,21 @@
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!--
|
||||
Sees incoming calls before they ring, so the car screen can show the caller and
|
||||
decline it. Inert until the driver sets Gearslip as the phone's caller ID and spam
|
||||
app, under Settings > Apps > Default apps - the same consent a call-blocking app asks
|
||||
for, not a privileged one.
|
||||
-->
|
||||
<service
|
||||
android:name=".call.GearslipCallScreening"
|
||||
android:exported="true"
|
||||
android:label="Gearslip calls"
|
||||
android:permission="android.permission.BIND_SCREENING_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.telecom.CallScreeningService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
Binary file not shown.
@@ -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)
|
||||
@@ -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
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
/** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */
|
||||
object AppSettings {
|
||||
@@ -8,7 +10,9 @@ object AppSettings {
|
||||
private const val PREFS = "gearslip_settings"
|
||||
private const val KEY_STARTUP_URL = "startup_url"
|
||||
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
|
||||
private const val KEY_DEBUG_MODE = "debug_mode"
|
||||
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
|
||||
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
|
||||
private const val KEY_GEARSLIP_ENABLED = "gearslip_enabled"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -36,15 +40,58 @@ object AppSettings {
|
||||
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
|
||||
* preview) on the home screen. On by default while the project is pre-release.
|
||||
*/
|
||||
fun debugMode(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_DEBUG_MODE, true)
|
||||
/** Whether the certificate setup screen has been deliberately skipped. */
|
||||
fun hasSkippedCertSetup(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false)
|
||||
|
||||
fun setDebugMode(context: Context, value: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply()
|
||||
GearslipLog.debug = value
|
||||
fun setSkippedCertSetup(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SKIPPED_CERT_SETUP, true).apply()
|
||||
}
|
||||
|
||||
/** Whether the one-by-one permissions wizard has already run once. */
|
||||
fun hasSeenPermissionsSetup(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_SEEN_PERMISSIONS_SETUP, false)
|
||||
|
||||
fun setSeenPermissionsSetup(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SEEN_PERMISSIONS_SETUP, true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Gearslip is allowed to claim the USB accessory connection. Off leaves the app
|
||||
* 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 gearslipEnabled(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_GEARSLIP_ENABLED, true)
|
||||
|
||||
fun setGearslipEnabled(context: Context, value: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_GEARSLIP_ENABLED, value).apply()
|
||||
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,28 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Who has the car's screen: Gearslip, or the car's own interface. Android Auto's "Exit" hands the
|
||||
* screen back to the car with the session still running, so music keeps playing; the car's own
|
||||
* Android Auto button brings Gearslip back. [GearslipRunner] fills in [request] while connected.
|
||||
*/
|
||||
object CarFocus {
|
||||
|
||||
private val projectedFlow = MutableStateFlow(true)
|
||||
|
||||
/** False while the car shows its own interface. */
|
||||
val projected: StateFlow<Boolean> = projectedFlow
|
||||
|
||||
@Volatile internal var request: (() -> Unit)? = null
|
||||
|
||||
/** Asks the car to show its own interface. Does nothing when no car is connected. */
|
||||
fun exitToCar() {
|
||||
request?.invoke()
|
||||
}
|
||||
|
||||
internal fun set(projected: Boolean) {
|
||||
projectedFlow.value = projected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Fetches the publicly available head unit certificate and key from the opencardev/aasdk
|
||||
* repository on GitHub. The app ships no certificates; it downloads them only when the
|
||||
* user chooses to, the same way a browser would.
|
||||
*/
|
||||
object CertDownloader {
|
||||
|
||||
private val log = GearslipLog.tagged("DOWNL")
|
||||
|
||||
private const val BASE = "https://raw.githubusercontent.com/opencardev/aasdk/main/cert"
|
||||
private const val CRT = "$BASE/headunit.crt"
|
||||
private const val KEY = "$BASE/headunit.key"
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 15_000
|
||||
|
||||
class Downloaded(val certificatePem: String, val privateKeyPem: String)
|
||||
|
||||
/** Fetches both files; throws with a readable message on any failure. */
|
||||
fun fetch(): Downloaded {
|
||||
log.i("fetching certificate from $BASE")
|
||||
val crt = fetch(CRT, "certificate")
|
||||
val key = fetch(KEY, "private key")
|
||||
log.i("downloaded certificate (${crt.length} bytes) and key (${key.length} bytes)")
|
||||
return Downloaded(crt, key)
|
||||
}
|
||||
|
||||
private fun fetch(url: String, label: String): String {
|
||||
val body = try {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
}
|
||||
conn.inputStream.use { input ->
|
||||
val out = ByteArrayOutputStream()
|
||||
val buf = ByteArray(4096)
|
||||
var n: Int
|
||||
while (input.read(buf).also { n = it } != -1) out.write(buf, 0, n)
|
||||
out.toString(Charsets.UTF_8)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("could not download the $label: ${e.message}", e)
|
||||
}
|
||||
if (body.isBlank()) throw RuntimeException("the $label file is empty")
|
||||
return body
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.X509Certificate
|
||||
@@ -14,7 +15,9 @@ import java.security.cert.X509Certificate
|
||||
*
|
||||
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
|
||||
* copied into the app's private storage.
|
||||
* 2. **Staged over adb** into the app's external files directory, the way the spike has
|
||||
* 2. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
|
||||
* user asks; the app never ships or hosts the certificate.
|
||||
* 3. **Staged over adb** into the app's external files directory, the way the spike has
|
||||
* always done it. Still honoured, so existing setups keep working untouched:
|
||||
*
|
||||
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
|
||||
@@ -23,7 +26,7 @@ import java.security.cert.X509Certificate
|
||||
*
|
||||
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
|
||||
* regenerating); a `phone.pass` file next to it overrides it.
|
||||
* 3. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||
* SPIKE_FINDINGS.md).
|
||||
*/
|
||||
object CertProvider {
|
||||
@@ -34,7 +37,7 @@ object CertProvider {
|
||||
private const val DEFAULT_PASSWORD = "aaspike"
|
||||
private const val IMPORT_DIR = "identity"
|
||||
|
||||
enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED }
|
||||
enum class Kind { IMPORTED, DOWNLOADED, ADB_STAGED, SELF_SIGNED }
|
||||
|
||||
class Identity(
|
||||
val keyStore: KeyStore,
|
||||
@@ -44,7 +47,10 @@ object CertProvider {
|
||||
val kind: Kind = Kind.SELF_SIGNED,
|
||||
)
|
||||
|
||||
/** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */
|
||||
/**
|
||||
* The identity for the next connection. A newly imported or pushed cert takes effect on the
|
||||
* next connect: see [loadFromFiles] for how the cache notices.
|
||||
*/
|
||||
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
|
||||
val generated = SelfSignedCert.generate()
|
||||
Identity(
|
||||
@@ -62,7 +68,38 @@ object CertProvider {
|
||||
*/
|
||||
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
|
||||
|
||||
/**
|
||||
* Unlocking a PKCS#12 file takes about a second on a Pixel 6, which a head unit waiting for
|
||||
* its first reply may not wait out. So the unlocked identity is kept, keyed on the files it
|
||||
* could come from: importing, downloading, removing or pushing a cert changes the key.
|
||||
*/
|
||||
@Volatile private var cached: Pair<String, Identity>? = null
|
||||
|
||||
private fun sourcesKey(context: Context): String {
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
val files = listOf(
|
||||
importedFile(context, P12_NAME),
|
||||
downloadedFile(context, P12_NAME),
|
||||
dir?.let { File(it, P12_NAME) },
|
||||
dir?.let { File(it, PASS_NAME) },
|
||||
)
|
||||
return files.joinToString("|") { f -> f?.let { "${it.path}:${it.lastModified()}:${it.length()}" } ?: "-" }
|
||||
}
|
||||
|
||||
/** Unlocks the certificate ahead of time, so the first connection doesn't wait on it. */
|
||||
fun warm(context: Context) {
|
||||
runCatching { loadFromFiles(context) }
|
||||
}
|
||||
|
||||
private fun loadFromFiles(context: Context): Identity? {
|
||||
val key = sourcesKey(context)
|
||||
cached?.let { (k, identity) -> if (k == key) return identity }
|
||||
val identity = unlockFromFiles(context) ?: return null
|
||||
cached = key to identity
|
||||
return identity
|
||||
}
|
||||
|
||||
private fun unlockFromFiles(context: Context): Identity? {
|
||||
val imported = importedFile(context, P12_NAME)
|
||||
if (imported.isFile) {
|
||||
runCatching {
|
||||
@@ -70,6 +107,13 @@ object CertProvider {
|
||||
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
|
||||
}
|
||||
|
||||
val downloaded = downloadedFile(context, P12_NAME)
|
||||
if (downloaded.isFile) {
|
||||
runCatching {
|
||||
return loadP12(downloaded, DEFAULT_PASSWORD, "downloaded certificate", Kind.DOWNLOADED)
|
||||
}.onFailure { log.e("could not load the downloaded certificate - trying the next source", it) }
|
||||
}
|
||||
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
val staged = dir?.let { File(it, P12_NAME) }
|
||||
if (dir != null && staged != null && staged.isFile) {
|
||||
@@ -113,17 +157,84 @@ object CertProvider {
|
||||
|
||||
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
|
||||
|
||||
/** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */
|
||||
/** Whether a downloaded certificate is stored (may or may not also be imported). */
|
||||
fun hasDownloaded(context: Context): Boolean = downloadedFile(context, P12_NAME).isFile
|
||||
|
||||
/** Drops the imported identity; the next source in line (downloaded, adb-staged, then self-signed) takes over. */
|
||||
fun removeImported(context: Context) {
|
||||
importedFile(context, P12_NAME).delete()
|
||||
importedFile(context, PASS_NAME).delete()
|
||||
log.i("removed the imported certificate")
|
||||
}
|
||||
|
||||
/** Drops the downloaded identity. */
|
||||
fun removeDownloaded(context: Context) {
|
||||
downloadedFile(context, P12_NAME).delete()
|
||||
downloadedFile(context, PASS_NAME).delete()
|
||||
log.i("removed the downloaded certificate")
|
||||
}
|
||||
|
||||
/** Whether any certificate is available (imported, downloaded, or adb-staged). */
|
||||
fun hasAnyCert(context: Context): Boolean =
|
||||
hasImported(context) || hasDownloaded(context) || hasAdbStaged(context)
|
||||
|
||||
private fun hasAdbStaged(context: Context): Boolean =
|
||||
context.getExternalFilesDir(null)?.let { File(it, P12_NAME).isFile } == true
|
||||
|
||||
/**
|
||||
* Validates raw PKCS#12 bytes with [password] and, if good, writes them as the imported
|
||||
* identity. Used when the app assembles a certificate in memory (e.g. from a download)
|
||||
* rather than importing from a file picker.
|
||||
*/
|
||||
fun importFromBytes(context: Context, p12Bytes: ByteArray, password: String): Identity {
|
||||
val staging = File(context.cacheDir, "import-$P12_NAME")
|
||||
try {
|
||||
staging.writeBytes(p12Bytes)
|
||||
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
|
||||
|
||||
val target = importedFile(context, P12_NAME)
|
||||
target.parentFile?.mkdirs()
|
||||
staging.copyTo(target, overwrite = true)
|
||||
importedFile(context, PASS_NAME).writeText(password)
|
||||
log.i("imported a certificate from bytes: ${identity.certificate.subjectX500Principal}")
|
||||
return identity
|
||||
} finally {
|
||||
staging.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the publicly available head unit certificate from the opencardev/aasdk
|
||||
* repository, converts it from PEM to PKCS#12, and stores it ready for use.
|
||||
* Runs synchronously (caller is responsible for threading). Throws on any failure.
|
||||
*/
|
||||
fun downloadAndImport(context: Context): Identity {
|
||||
val download = CertDownloader.fetch()
|
||||
val result = PemKeyParser.build(download.certificatePem, download.privateKeyPem)
|
||||
|
||||
val target = downloadedFile(context, P12_NAME)
|
||||
target.parentFile?.mkdirs()
|
||||
val p12Password = DEFAULT_PASSWORD
|
||||
val keyStore = result.keyStore
|
||||
val p12Bytes = ByteArrayOutputStream().use { stream ->
|
||||
keyStore.store(stream, p12Password.toCharArray())
|
||||
stream.toByteArray()
|
||||
}
|
||||
target.writeBytes(p12Bytes)
|
||||
downloadedFile(context, PASS_NAME).writeText(p12Password)
|
||||
|
||||
log.i("downloaded and stored certificate: ${result.certificate.subjectX500Principal}")
|
||||
return Identity(keyStore, result.certificate, p12Password.toCharArray(),
|
||||
"downloaded certificate (opencardev/aasdk)", Kind.DOWNLOADED)
|
||||
}
|
||||
|
||||
// The imported files live in the app's private internal storage (not the external files
|
||||
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
|
||||
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
|
||||
|
||||
private fun downloadedFile(context: Context, name: String) =
|
||||
File(File(context.filesDir, "$IMPORT_DIR-downloaded"), name)
|
||||
|
||||
private fun importedPassword(context: Context): String =
|
||||
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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(
|
||||
8, "0.2.00-alpha",
|
||||
listOf(
|
||||
"Exit takes you to the car's own screen. Tap Android Auto on the car to come back.",
|
||||
"Gearslip listens through the car's microphone when the car has one, so voice works with the phone in a pocket.",
|
||||
"A voice reply sends when its bar runs out, even over road noise or the radio. Say \"cancel\" or \"change it\" before then.",
|
||||
"Maps turn dark when the car switches to night mode.",
|
||||
"A moving map no longer makes the car screen fall behind your taps.",
|
||||
"Gearslip asks to send sound to the car as soon as you connect, so videos in the web browser play in the car too.",
|
||||
"Cars that offer more than one kind of music audio, such as the 2022 Dacia Jogger, play sound again.",
|
||||
"When the car shows a keyboard, you can type on the phone instead. The car's keyboard is taller too.",
|
||||
"Every car screen has a back button.",
|
||||
"App icons show whether an app works in Gearslip. Metrolist works and shows the green check.",
|
||||
"LIVI connects again, and touch works on cars with a second screen.",
|
||||
"The phone's Settings screen has a new layout, with What's new and the setup guide at the top.",
|
||||
"You can choose to send short usage notes that help the project. They hold no name, account, or location, and they're off unless you turn them on.",
|
||||
"Searching inside a media app moved to Settings > Experimental features.",
|
||||
"Under Settings > Media audio, the choices are now Phone audio and Car audio.",
|
||||
"Logs leave out your location and the car's serial number.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
7, "0.1.10-alpha",
|
||||
listOf(
|
||||
"The launcher groups your apps: Phone, Maps, Music, Messaging, Web, Screen sharing, and Settings.",
|
||||
"To move an app, press and hold it on the launcher, then tap Move left or Move right.",
|
||||
"MapQuest and Flitsmeister work in Gearslip and show the green check.",
|
||||
"Spotify has one tile. If Spotify isn't open on the phone, tap Open Spotify to start it.",
|
||||
"Flick a map and it glides on, as it does in Android Auto.",
|
||||
"The map picks up where it left off when you come back from another screen.",
|
||||
"Search boxes in map apps are larger and easier to read.",
|
||||
"Map apps keep the right icons on their buttons after they update.",
|
||||
"When Gearslip asks \"Do you want to reply?\", a bar shows how long it will listen. The bar stops while you speak.",
|
||||
"Gearslip fits its picture to wide car screens and keeps it clear of the edges the car crops.",
|
||||
"Gearslip answers the car's connection checks correctly. Some cars, Audis among them, hung up after a second without this.",
|
||||
"Google Maps no longer shows up as a messaging app.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
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 }
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import java.io.FileInputStream
|
||||
@@ -40,6 +41,8 @@ import java.io.FileOutputStream
|
||||
* rotation or theme change from recreating it and dropping the connection.
|
||||
*/
|
||||
class GearslipActivity : ComponentActivity(), Projection {
|
||||
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
private var runner: GearslipRunner? = null
|
||||
@@ -94,32 +97,18 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
|
||||
private val locationPermission = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
||||
|
||||
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
|
||||
private val calendarPermission = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
||||
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
||||
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR)
|
||||
// Playback capture needs the microphone even though it never records one. Ask for it here,
|
||||
// at the kerb, rather than the first time a song plays: a permission dialog that appears
|
||||
// mid-drive is one the driver has to read and answer at the wheel, and missing it is
|
||||
// indistinguishable from the car link being broken.
|
||||
if (CarSettings.pipeAudio.value &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
||||
// Location, calendar, the phone/contacts group, and the microphone are asked for one at
|
||||
// a time by PermissionsSetupScreen instead of here, each only after the driver taps
|
||||
// Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted,
|
||||
// is both a bad first impression and unreliable: Android does not consistently show more
|
||||
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
||||
enableEdgeToEdge()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
@@ -127,6 +116,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
GearslipLog.installCrashHandler()
|
||||
GearslipLog.i("Gearslip ready")
|
||||
CarSettings.init(this)
|
||||
AppSettings.applyGearslipEnabled(this)
|
||||
// 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.
|
||||
if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) {
|
||||
@@ -137,7 +127,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
|
||||
setContent {
|
||||
GearslipTheme {
|
||||
GearslipApp(onDisconnect = ::closeAccessory)
|
||||
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Prefetch.warm(this)
|
||||
UsageStats.onAppOpened(this)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -181,6 +172,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
/** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
|
||||
private fun requestCallScreeningRole() {
|
||||
val roleManager = getSystemService(android.app.role.RoleManager::class.java)
|
||||
if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
|
||||
GearslipLog.w("calls: call screening role unavailable on this phone")
|
||||
return
|
||||
}
|
||||
if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
|
||||
runCatching {
|
||||
callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
|
||||
}.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
|
||||
}
|
||||
|
||||
// --- Media audio --------------------------------------------------------------------
|
||||
|
||||
private fun requestAudioCapture() {
|
||||
@@ -246,6 +250,12 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
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)
|
||||
if (accessory == null) {
|
||||
GearslipLog.e("attach intent carried no accessory")
|
||||
@@ -257,10 +267,13 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
|
||||
private fun start(accessory: UsbAccessory) {
|
||||
if (worker?.isAlive == true) {
|
||||
GearslipLog.w("a run is already in progress")
|
||||
return
|
||||
}
|
||||
// A fresh ATTACHED intent means the system wants a new session, whatever state the old
|
||||
// one is in - closing first guarantees openAccessory() below never fights a descriptor
|
||||
// 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)
|
||||
|
||||
val manager = getSystemService(UsbManager::class.java)
|
||||
@@ -307,11 +320,16 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
CarEnvironment.setDisplay(width, height, densityDpi)
|
||||
CarEnvironment.start(this)
|
||||
showCarUi()
|
||||
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
|
||||
runOnUiThread { screenProjector?.dispatchKey(code, down) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||
runOnUiThread {
|
||||
// To the front of the main thread's queue: a touch waiting behind other work is lag the
|
||||
// driver feels, while the work it jumps ahead of can wait a frame.
|
||||
mainHandler.postAtFrontOfQueue {
|
||||
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
||||
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
||||
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
||||
@@ -321,6 +339,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
override fun onProjectionStopped() {
|
||||
runOnUiThread {
|
||||
PhoneMirror.requestStop()
|
||||
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
|
||||
screenProjector?.stop()
|
||||
screenProjector = null
|
||||
surface = null
|
||||
|
||||
@@ -32,9 +32,9 @@ object GearslipLog {
|
||||
private var file: File? = null
|
||||
|
||||
/**
|
||||
* Set from [AppSettings.debugMode]. Off drops the noisy trace output (hex dumps, and
|
||||
* logcat copies of info lines); warnings, errors and the file copy are always kept, so a
|
||||
* crash report is never empty.
|
||||
* Off drops the noisy trace output (hex dumps, and logcat copies of info lines); warnings,
|
||||
* errors and the file copy are always kept, so a crash report is never empty. Always on for
|
||||
* now, while the project is pre-release.
|
||||
*/
|
||||
@Volatile var debug = true
|
||||
|
||||
@@ -47,13 +47,11 @@ object GearslipLog {
|
||||
val version: StateFlow<Int> = liveVersion
|
||||
|
||||
fun init(context: Context) {
|
||||
debug = AppSettings.debugMode(context)
|
||||
val dir = context.getExternalFilesDir(null) ?: return
|
||||
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
file = File(dir, "gearslip-$name.log")
|
||||
i("log file: ${file?.absolutePath}")
|
||||
SessionReport.init(context)
|
||||
i("debug mode: $debug")
|
||||
}
|
||||
|
||||
fun snapshot(): List<String> = synchronized(live) { live.toList() }
|
||||
|
||||
@@ -2,9 +2,16 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import android.os.Build
|
||||
import app.seb3thehacker.gearslip.audio.AudioLink
|
||||
import app.seb3thehacker.gearslip.audio.CarMic
|
||||
import app.seb3thehacker.gearslip.car.CarAssistant
|
||||
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.OutputStream
|
||||
import java.util.concurrent.FutureTask
|
||||
|
||||
/**
|
||||
* Drives the phone side of the control-channel handshake far enough to answer one question:
|
||||
@@ -33,7 +40,13 @@ class GearslipRunner(
|
||||
|
||||
private val parser = Frames.Parser()
|
||||
private val assembler = Frames.Assembler()
|
||||
private lateinit var tls: PhoneTls
|
||||
private lateinit var identityTask: FutureTask<PhoneTls>
|
||||
|
||||
/** Sends every message, in order, so no other thread ever blocks on the USB link. See [send]. */
|
||||
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor { Thread(it, "gearslip-writer") }
|
||||
|
||||
/** Waits for the certificate only when TLS is first needed; see [run]. */
|
||||
private val tls: PhoneTls by lazy { identityTask.get() }
|
||||
|
||||
@Volatile private var state = State.WAIT_VERSION
|
||||
@Volatile private var lastProgress = System.currentTimeMillis()
|
||||
@@ -46,10 +59,16 @@ class GearslipRunner(
|
||||
@Volatile private var videoStarted = false
|
||||
private var videoSource: VideoSource? = null
|
||||
@Volatile private var inFlight = 0
|
||||
/** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */
|
||||
private val ackLock = Object()
|
||||
/** Time the encoder spent waiting for acks since the last stats line. */
|
||||
@Volatile private var ackWaitMs = 0L
|
||||
@Volatile private var maxUnacked = 4
|
||||
@Volatile private var framesSent = 0
|
||||
@Volatile private var dropped = 0
|
||||
@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
|
||||
private var audioMessagesSeen = 0
|
||||
private var touchWidth = 0
|
||||
@@ -68,14 +87,19 @@ class GearslipRunner(
|
||||
SessionStatus.connecting("Connected to the head unit")
|
||||
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
|
||||
|
||||
val identity = identityProvider()
|
||||
val cert = identity.certificate
|
||||
log.i("phone certificate source: ${identity.source}")
|
||||
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
||||
log.i(" subject = ${cert.subjectX500Principal}")
|
||||
log.i(" issuer = ${cert.issuerX500Principal}")
|
||||
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||
tls = PhoneTls(identity.keyStore, identity.password)
|
||||
// The head unit speaks first, and some give up if the phone doesn't read within about a
|
||||
// second. The version exchange needs no certificate, so read at once and unlock the
|
||||
// certificate alongside; the TLS handshake waits for it.
|
||||
identityTask = FutureTask {
|
||||
val identity = identityProvider()
|
||||
val cert = identity.certificate
|
||||
log.i("phone certificate source: ${identity.source}")
|
||||
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
||||
log.i(" subject = ${cert.subjectX500Principal}")
|
||||
log.i(" issuer = ${cert.issuerX500Principal}")
|
||||
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
|
||||
PhoneTls(identity.keyStore, identity.password)
|
||||
}.also { Thread(it, "gearslip-identity").start() }
|
||||
|
||||
startWatchdog()
|
||||
|
||||
@@ -107,10 +131,18 @@ class GearslipRunner(
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
writer.shutdownNow()
|
||||
CarFocus.request = null
|
||||
CarFocus.set(true)
|
||||
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
||||
videoSource?.stop()
|
||||
videoSource = null
|
||||
audioLink?.close()
|
||||
audioLink = null
|
||||
CarSensors.clear()
|
||||
CarMic.current = null
|
||||
lastSensorNight = null
|
||||
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
|
||||
projection?.onProjectionStopped()
|
||||
SessionStatus.disconnected()
|
||||
}
|
||||
@@ -142,6 +174,10 @@ class GearslipRunner(
|
||||
onVideoMessage(messageId, body)
|
||||
} else if (frame.channel == inputChannelId) {
|
||||
onInputMessage(messageId, body)
|
||||
} else if (frame.channel == sensorChannelId) {
|
||||
onSensorMessage(messageId, body)
|
||||
} else if (frame.channel == CarMic.current?.channelId) {
|
||||
CarMic.current?.onMessage(messageId, body)
|
||||
} else if (frame.channel == audioLink?.channelId) {
|
||||
// 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.
|
||||
@@ -166,11 +202,34 @@ class GearslipRunner(
|
||||
MSG_AUTH_COMPLETE -> onAuthComplete(body)
|
||||
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
||||
MSG_PING_REQUEST -> onPingRequest(body)
|
||||
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
|
||||
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
|
||||
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) {
|
||||
if (body.size < 4) {
|
||||
log.w("VersionRequest too short (${body.size} bytes)")
|
||||
@@ -181,16 +240,22 @@ class GearslipRunner(
|
||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||
SessionReport.protocolVersion(major, minor)
|
||||
|
||||
// Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
|
||||
// asserting 1.6 is the permissive choice: we only need to reach service discovery,
|
||||
// not to honour whatever that version implies.
|
||||
// PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert;
|
||||
// the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check
|
||||
// is tied to the *negotiated* protocol version rather than baked into the firmware,
|
||||
// claiming an older version makes the newer unit skip the check entirely. CLAIM_VERSION
|
||||
// = null restores the previous echo-the-head-unit behaviour.
|
||||
val claim = CLAIM_VERSION ?: (major to minor)
|
||||
val response = byteArrayOf(
|
||||
((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
|
||||
((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
|
||||
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
||||
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
||||
0, 0, // STATUS_SUCCESS
|
||||
)
|
||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
||||
log.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
|
||||
log.i(
|
||||
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
|
||||
if (CLAIM_VERSION != null) " [FORCED; head unit offered $major.$minor]" else "",
|
||||
)
|
||||
state = State.TLS_HANDSHAKE
|
||||
SessionStatus.connecting("Securing the link")
|
||||
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||
@@ -287,7 +352,12 @@ class GearslipRunner(
|
||||
|
||||
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
|
||||
log.i("head unit describes itself as:\n" + Protobuf.describe(body))
|
||||
// Field 5 is the car's serial number, and the Bluetooth service carries the car's
|
||||
// Bluetooth address. Either one identifies the car, and logs get shared, so both stay out.
|
||||
val described = Protobuf.describe(body)
|
||||
.replace(Regex("(?m)^ #5 string = .*$"), " #5 string = (car serial, left out)")
|
||||
.replace(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"), "(Bluetooth address, left out)")
|
||||
log.i("head unit describes itself as:\n" + described)
|
||||
log.verdict(
|
||||
"VIABLE",
|
||||
"The head unit accepted the presented phone certificate, completed TLS, and is " +
|
||||
@@ -298,6 +368,7 @@ class GearslipRunner(
|
||||
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
||||
log.i("head unit: $info")
|
||||
SessionReport.headUnit(info.toString())
|
||||
SessionReport.headUnitInfo = info
|
||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||
val profile = vehicleProfile
|
||||
SessionReport.profile(profile?.name)
|
||||
@@ -334,7 +405,7 @@ class GearslipRunner(
|
||||
videoChannelId = video.serviceId
|
||||
videoConfigs = video.configs
|
||||
|
||||
log.i("video service: channel=${video.serviceId} codec=${video.codecName}")
|
||||
log.i("video service: channel=${video.serviceId} codec=${video.codecName} display=${video.displayId}")
|
||||
video.configs.forEachIndexed { index, config ->
|
||||
log.i(" config[$index] = $config")
|
||||
}
|
||||
@@ -344,17 +415,20 @@ class GearslipRunner(
|
||||
|
||||
openChannel(video.serviceId)
|
||||
startAudioChannel(serviceDiscoveryResponse)
|
||||
startSensorChannel(serviceDiscoveryResponse)
|
||||
startMicChannel(serviceDiscoveryResponse)
|
||||
|
||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
|
||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
||||
if (input == null) {
|
||||
log.w("no input service advertised - projection will be output-only")
|
||||
} else {
|
||||
inputChannelId = input.serviceId
|
||||
inputKeycodes = input.keycodesSupported
|
||||
touchWidth = input.touchWidth
|
||||
touchHeight = input.touchHeight
|
||||
log.i(
|
||||
"input service: channel=${input.serviceId} touchscreen=" +
|
||||
"${input.touchWidth}x${input.touchHeight}",
|
||||
"${input.touchWidth}x${input.touchHeight} keycodes=${input.keycodesSupported}",
|
||||
)
|
||||
openChannel(input.serviceId)
|
||||
}
|
||||
@@ -369,13 +443,16 @@ class GearslipRunner(
|
||||
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
|
||||
)
|
||||
}
|
||||
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run {
|
||||
val media = ServiceDiscovery.pickMediaSink(sinks) ?: run {
|
||||
log.w("no media audio sink advertised - media apps will play on the phone only")
|
||||
SessionReport.audio(
|
||||
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (media.codecType != ServiceDiscovery.CODEC_PCM) {
|
||||
log.w("the car offers no PCM media sink, only ${media.codecName}; it may play our PCM as silence")
|
||||
}
|
||||
val first = media.configs.firstOrNull()
|
||||
SessionReport.audio(
|
||||
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
|
||||
@@ -389,6 +466,119 @@ class GearslipRunner(
|
||||
openChannel(media.serviceId)
|
||||
}
|
||||
|
||||
/** The car's microphone, for voice: opened now, recording only while something listens. */
|
||||
private fun startMicChannel(serviceDiscoveryResponse: ByteArray) {
|
||||
val mic = ServiceDiscovery.findMicService(serviceDiscoveryResponse) ?: run {
|
||||
log.i("no microphone advertised - voice uses the phone's")
|
||||
return
|
||||
}
|
||||
val config = mic.configs.first()
|
||||
log.i("car microphone: channel=${mic.serviceId} ${mic.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}")
|
||||
CarMic.current = CarMic(mic) { id, body -> send(id, body, encrypted = true, channel = mic.serviceId) }
|
||||
openChannel(mic.serviceId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sensor type the head unit advertised, subscribed to at once - there is no screen
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* The car may stream sensors many times a second, on the same thread that reads its audio and
|
||||
* video messages. Log each type's first reading in full, then only a count every
|
||||
* [SENSOR_LOG_EVERY_MS], so the log can never hold that thread up.
|
||||
*/
|
||||
private val sensorTypesLogged = HashSet<Int>()
|
||||
private var sensorEventsSinceLog = 0
|
||||
private var sensorLoggedAt = 0L
|
||||
|
||||
private fun logSensorEvent(body: ByteArray) {
|
||||
val types = Wire.fields(body).map { it.number }.toSet()
|
||||
if (sensorTypesLogged.addAll(types)) {
|
||||
// Type 1 is the car's GPS position; logs get shared, so say it arrived and nothing more.
|
||||
if (SENSOR_LOCATION in types) log.i("<- SensorEvent (first of its kind): types=$types, location left out")
|
||||
else log.i("<- SensorEvent (first of its kind):\n" + Protobuf.describe(body))
|
||||
}
|
||||
sensorEventsSinceLog++
|
||||
val now = System.currentTimeMillis()
|
||||
if (sensorLoggedAt == 0L) sensorLoggedAt = now
|
||||
if (now - sensorLoggedAt >= SENSOR_LOG_EVERY_MS) {
|
||||
log.i("<- $sensorEventsSinceLog sensor events in the last ${(now - sensorLoggedAt) / 1000}s")
|
||||
sensorEventsSinceLog = 0
|
||||
sensorLoggedAt = now
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's own day/night signal (its headlights or light sensor): NightData { bool is_night = 1; }.
|
||||
* It replaces the clock guess, so maps go dark when the car does.
|
||||
*/
|
||||
private fun onNightSensor(body: ByteArray) {
|
||||
val night = Wire.bytes(Wire.fields(body), SENSOR_NIGHT) ?: return
|
||||
val isNight = Wire.varint(Wire.fields(night), 1) == 1L
|
||||
if (isNight != lastSensorNight) {
|
||||
lastSensorNight = isNight
|
||||
log.i("car says it's ${if (isNight) "night" else "day"}")
|
||||
CarEnvironment.setSensorNight(isNight)
|
||||
}
|
||||
}
|
||||
|
||||
private var lastSensorNight: Boolean? = null
|
||||
|
||||
private fun onSensorMessage(messageId: Int, body: ByteArray) {
|
||||
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 SensorType type = 1; required int64 min_update_period = 2; }
|
||||
// A period of 0 asks for every update the car sends.
|
||||
sensorTypes.forEach { type ->
|
||||
send(
|
||||
MSG_SENSOR_START_REQUEST,
|
||||
Protobuf.varintField(1, type.toLong()) + Protobuf.varintField(2, 0L),
|
||||
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)
|
||||
logSensorEvent(body)
|
||||
onNightSensor(body)
|
||||
}
|
||||
|
||||
// SensorError { required SensorType sensor_type = 1; required SensorErrorType sensor_error_type = 2; }
|
||||
MSG_SENSOR_ERROR -> log.w(
|
||||
"<- SensorError: type=${Protobuf.readInt32Field(body, 1)} error=${Protobuf.readInt32Field(body, 2)}",
|
||||
)
|
||||
|
||||
else -> log.i("<- unhandled sensor message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun openChannel(serviceId: Int) {
|
||||
val request = Protobuf.varintField(1, 0L) + // priority (sint32 zigzag: 0 -> 0)
|
||||
Protobuf.varintField(2, serviceId.toLong()) // service_id
|
||||
@@ -414,14 +604,27 @@ class GearslipRunner(
|
||||
*/
|
||||
private fun onInputMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE ->
|
||||
log.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
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 -> {
|
||||
val report = Wire.fields(body)
|
||||
// The 2018 Uconnect agrees to Exit but keeps showing our last frame and sending
|
||||
// touches, which looked like a crash. A touch means our screen is still up there.
|
||||
if (videoStarted && !videoProjected && Wire.bytes(report, 3) != null) {
|
||||
log.w("the car said it took its screen back but still sends touches - showing ours again")
|
||||
onVideoFocus(VIDEO_FOCUS_PROJECTED)
|
||||
}
|
||||
Wire.bytes(report, 4)?.let(::onKeyEvent)
|
||||
Wire.bytes(report, 6)?.let(::onRelativeEvent)
|
||||
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
|
||||
if (touch == null) {
|
||||
log.i("<- InputReport with no touch payload (key or rotary event)")
|
||||
return
|
||||
}
|
||||
val touchFields = Wire.fields(touch)
|
||||
@@ -447,6 +650,8 @@ class GearslipRunner(
|
||||
val at = points[actionIndex]
|
||||
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
|
||||
}
|
||||
// Time the next frame against this tap: see [reportTouchLatency].
|
||||
if (action == ACTION_DOWN || action == ACTION_UP) touchAtUs = System.nanoTime() / 1000
|
||||
projection?.onTouch(action, actionIndex, points)
|
||||
}
|
||||
|
||||
@@ -454,6 +659,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) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
@@ -483,15 +736,11 @@ class GearslipRunner(
|
||||
"configuration_indices=$indices",
|
||||
)
|
||||
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 preferred = vehicleProfile?.resolution?.let { want ->
|
||||
offered.firstOrNull { videoConfigs.getOrNull(it)?.resolutionName == want }
|
||||
}
|
||||
selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
|
||||
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
|
||||
} ?: offered.firstOrNull()
|
||||
selectedConfigIndex = preferred ?: fitToScreen(offered) ?: offered.firstOrNull()
|
||||
log.i("selected config index $selectedConfigIndex from offered $offered")
|
||||
SessionReport.video(
|
||||
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
||||
@@ -505,22 +754,28 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
// VideoFocusNotification { focus = 1; unsolicited = 2 }
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
|
||||
// The head unit repeats this while it waits for frames; only act once.
|
||||
if (!videoStarted) {
|
||||
videoStarted = true
|
||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
startVideoSource()
|
||||
}
|
||||
val mode = Wire.varint(fields, 1)?.toInt() ?: VIDEO_FOCUS_PROJECTED
|
||||
log.i("<- VideoFocusNotification: mode=$mode unsolicited=${Wire.varint(fields, 2)}")
|
||||
onVideoFocus(mode)
|
||||
}
|
||||
|
||||
// Not in aasdk's direction of travel, but LIVI sends it: log what it asks for.
|
||||
MSG_VIDEO_FOCUS_REQUEST -> {
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- VideoFocusRequest from the head unit: mode=${Wire.varint(fields, 2)} reason=${Wire.varint(fields, 3)}")
|
||||
}
|
||||
|
||||
MSG_MEDIA_ACK -> {
|
||||
val fields = Wire.fields(body)
|
||||
val acked = Wire.varint(fields, 2)?.toInt() ?: 1
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
acksSeen++
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
synchronized(ackLock) {
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
acksSeen++
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
if (framesSent <= 3 || framesSent % 60 == 0) {
|
||||
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
|
||||
}
|
||||
@@ -537,6 +792,20 @@ class GearslipRunner(
|
||||
// -> Data (8-byte big-endian timestamp, then the H.264 access unit)
|
||||
// <- 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() {
|
||||
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
|
||||
val index = selectedConfigIndex ?: 0
|
||||
@@ -549,11 +818,19 @@ class GearslipRunner(
|
||||
log.e("unsupported resolution ${config.resolutionName}")
|
||||
return
|
||||
}
|
||||
config.visibleSize()?.let { (w, h) -> SessionReport.carScreen("${w}x$h", config.density) }
|
||||
|
||||
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
||||
Protobuf.varintField(2, index.toLong())
|
||||
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
||||
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
|
||||
videoSource = VideoSource(
|
||||
@@ -562,6 +839,7 @@ class GearslipRunner(
|
||||
frameRate = if (config.frameRate == 1) 60 else 30,
|
||||
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
|
||||
onCodecConfig = { csd ->
|
||||
lastCodecConfig = csd
|
||||
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
|
||||
log.hex(" csd", csd, limit = 64)
|
||||
@@ -581,39 +859,34 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow control: the head unit allows max_unacked frames in flight (LIVI allows one). When
|
||||
* the window is full this waits for an ack instead of dropping the frame. A dropped frame
|
||||
* breaks the decoder's chain, so it cost a keyframe to repair; keyframes are twenty times
|
||||
* the size, so they caused more drops, and the stream sank into a storm of them (345 in a
|
||||
* minute on LIVI, at 5-12 fps). Waiting here holds the encoder back instead, which simply
|
||||
* makes it produce fewer frames, each one whole.
|
||||
*/
|
||||
private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) {
|
||||
// Flow control: the head unit told us max_unacked in its Config reply.
|
||||
if (inFlight >= maxUnacked) {
|
||||
// A dropped or never-sent ack would otherwise wedge the stream forever, which
|
||||
// looks identical to a frozen picture. Assume the window cleared and carry on.
|
||||
val since = System.currentTimeMillis() - lastAckAt
|
||||
if (since > ACK_STALL_MS) {
|
||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||
inFlight = 0
|
||||
} else {
|
||||
dropped++
|
||||
if (dropped % 30 == 1) {
|
||||
log.w("dropping frame - $inFlight in flight (max $maxUnacked)")
|
||||
// The car is showing its own screen and would throw these away.
|
||||
if (!videoProjected) return
|
||||
synchronized(ackLock) {
|
||||
val waitStart = System.currentTimeMillis()
|
||||
while (running && inFlight >= maxUnacked) {
|
||||
// A lost ack would otherwise hold the stream forever, which looks like a frozen
|
||||
// picture. After a long silence, assume the window cleared and carry on.
|
||||
val since = System.currentTimeMillis() - lastAckAt
|
||||
if (since > ACK_STALL_MS) {
|
||||
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis() // one warning per stall, not one per frame
|
||||
break
|
||||
}
|
||||
// A dropped frame breaks the decoder's reference chain on the head unit -
|
||||
// every frame after it is a P-frame assuming a picture that never arrived.
|
||||
// Without a resync the picture free-runs on corrupted state indefinitely,
|
||||
// which is the smeared/ghosted look.
|
||||
//
|
||||
// Ask on EVERY drop, not just the first in a streak: the resync keyframe we
|
||||
// asked for is itself just another frame, and can be dropped by this exact
|
||||
// same check. A one-shot request that gets swallowed by the burst that
|
||||
// provoked it leaves the stream corrupted for good, with no further request
|
||||
// ever made - confirmed on the road (docs/spike/phase-c-stuck-resync-2026-09-17.log,
|
||||
// dropped=27 in one burst, video dead at 0fps for 100+s afterward). Repeated
|
||||
// setParameters(REQUEST_SYNC_FRAME) calls before one lands are harmless - it
|
||||
// just keeps the "next frame is a keyframe" flag set.
|
||||
resyncRequested = true
|
||||
resyncAttempts++
|
||||
videoSource?.requestSyncFrame()
|
||||
return
|
||||
ackLock.wait(ACK_STALL_MS - since + 1)
|
||||
}
|
||||
ackWaitMs += System.currentTimeMillis() - waitStart
|
||||
}
|
||||
if (!running) return
|
||||
val timestamp = ByteArray(8)
|
||||
for (i in 0 until 8) {
|
||||
timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||
@@ -626,15 +899,11 @@ class GearslipRunner(
|
||||
resyncAttempts = 0
|
||||
lastKeyframeSentAt = System.currentTimeMillis()
|
||||
}
|
||||
try {
|
||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||
} catch (e: java.io.IOException) {
|
||||
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
return
|
||||
}
|
||||
inFlight++
|
||||
// Counted before it's queued: the writer may send it and the ack come back before this
|
||||
// thread runs again, and an ack counted ahead of its frame would leave the window full.
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
|
||||
reportTouchLatency(presentationTimeUs)
|
||||
framesSent++
|
||||
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
|
||||
log.i(
|
||||
@@ -644,6 +913,84 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the car is showing Gearslip; false while it shows its own interface. */
|
||||
@Volatile private var videoProjected = true
|
||||
/** The encoder's SPS/PPS, resent when the car hands its screen back. */
|
||||
@Volatile private var lastCodecConfig: ByteArray? = null
|
||||
|
||||
/**
|
||||
* The car says who has its screen. The first grant starts the video. After that the car may
|
||||
* take its screen back (our Exit, or its own buttons) and later return it: while it has the
|
||||
* screen nothing is sent, and on return the stream restarts from the codec config and a fresh
|
||||
* keyframe, since the car's decoder may have been reset in between.
|
||||
*/
|
||||
private fun onVideoFocus(mode: Int) {
|
||||
val projected = mode == VIDEO_FOCUS_PROJECTED || mode == VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||
if (!videoStarted) {
|
||||
// The head unit repeats this while it waits for frames; only start once.
|
||||
if (!projected) return
|
||||
videoStarted = true
|
||||
CarFocus.request = ::requestNativeFocus
|
||||
log.i("PHASE A COMPLETE - video focus granted; starting video source")
|
||||
startVideoSource()
|
||||
return
|
||||
}
|
||||
if (projected == videoProjected) return
|
||||
videoProjected = projected
|
||||
CarFocus.set(projected)
|
||||
if (projected) {
|
||||
log.i("the car handed its screen back - restarting the stream")
|
||||
// Frames sent just before the car took over may never be acked.
|
||||
synchronized(ackLock) {
|
||||
inFlight = 0
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
|
||||
Protobuf.varintField(2, (selectedConfigIndex ?: 0).toLong())
|
||||
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> Start(session_id=$SESSION_ID)")
|
||||
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
|
||||
videoSource?.requestSyncFrame()
|
||||
} else {
|
||||
// A phone ends the stream when the car takes its screen. LIVI switches on the focus
|
||||
// answer alone, but the 2018 Uconnect kept showing our last frame until it got Stop.
|
||||
send(MSG_MEDIA_STOP, Protobuf.varintField(1, SESSION_ID.toLong()), encrypted = true, channel = videoChannelId)
|
||||
log.i("the car is showing its own screen - video stopped, audio carries on")
|
||||
}
|
||||
}
|
||||
|
||||
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
|
||||
private fun requestNativeFocus() {
|
||||
if (!videoStarted) return
|
||||
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. The
|
||||
// display's channel is deprecated, but the 2018 Uconnect (protocol 1.3) predates that:
|
||||
// without it, it agreed to show its own screen and never did. LIVI ignores the field.
|
||||
val request = Protobuf.varintField(1, videoChannelId.toLong()) +
|
||||
Protobuf.varintField(2, VIDEO_FOCUS_NATIVE.toLong()) +
|
||||
Protobuf.varintField(3, VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong())
|
||||
send(MSG_VIDEO_FOCUS_REQUEST, request, encrypted = true, channel = videoChannelId)
|
||||
log.i("-> VideoFocusRequest(display=$videoChannelId, mode=NATIVE, reason=LAUNCH_NATIVE)")
|
||||
}
|
||||
|
||||
/** Arrival time of the last tap still waiting to be seen in a frame; 0 when none is. */
|
||||
@Volatile private var touchAtUs = 0L
|
||||
|
||||
/**
|
||||
* How long a tap takes to reach the car, split where the phone can see it: the UI drawing a
|
||||
* frame after the tap, the encoder compressing it, and the send. A frame's timestamp is when
|
||||
* the UI finished drawing it, on the same clock as [touchAtUs]. The head unit's own decode
|
||||
* and display come on top and can't be seen from here.
|
||||
*/
|
||||
private fun reportTouchLatency(presentationTimeUs: Long) {
|
||||
val touchAt = touchAtUs
|
||||
if (touchAt == 0L || presentationTimeUs <= touchAt) return
|
||||
touchAtUs = 0L
|
||||
val nowUs = System.nanoTime() / 1000
|
||||
val draw = (presentationTimeUs - touchAt) / 1000
|
||||
val encode = (nowUs - presentationTimeUs) / 1000
|
||||
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
||||
}
|
||||
|
||||
private fun clamp(value: Int, limit: Int?): Float = when {
|
||||
limit == null -> value.toFloat()
|
||||
value < 0 -> 0f
|
||||
@@ -652,9 +999,11 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
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)
|
||||
log.i("<- PingRequest / -> PingResponse (keeping the session alive)")
|
||||
log.i("<- PingRequest(timestamp=$timestamp) / -> PingResponse (keeping the session alive)")
|
||||
}
|
||||
|
||||
private fun send(
|
||||
@@ -668,35 +1017,49 @@ class GearslipRunner(
|
||||
((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(),
|
||||
) + body
|
||||
|
||||
synchronized(output) {
|
||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||
} else {
|
||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
||||
// separately, and carry the total plaintext length in the FIRST frame.
|
||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
||||
val chunk = payload.copyOfRange(offset, offset + size)
|
||||
val frameType = when {
|
||||
offset == 0 -> Frames.TYPE_FIRST
|
||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
||||
else -> Frames.TYPE_MIDDLE
|
||||
// Every write goes through one thread, in order. The reading thread must never wait on
|
||||
// a write: a head unit like LIVI writes its ack before it reads on, so a reader stuck
|
||||
// behind a big video frame (to answer a ping) and a head unit stuck writing that ack
|
||||
// held each other up until something timed out, about two seconds a time.
|
||||
try {
|
||||
writer.execute {
|
||||
try {
|
||||
if (payload.size <= Frames.MAX_FRAME_PAYLOAD) {
|
||||
val framePayload = if (encrypted) tls.encrypt(payload) else payload
|
||||
output.write(Frames.build(channel, encrypted, framePayload, messageType))
|
||||
} else {
|
||||
// Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk
|
||||
// separately, and carry the total plaintext length in the FIRST frame.
|
||||
// Video keyframes for anything busier than a test card exceed 16 KB routinely.
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset)
|
||||
val chunk = payload.copyOfRange(offset, offset + size)
|
||||
val frameType = when {
|
||||
offset == 0 -> Frames.TYPE_FIRST
|
||||
offset + size >= payload.size -> Frames.TYPE_LAST
|
||||
else -> Frames.TYPE_MIDDLE
|
||||
}
|
||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
||||
output.write(
|
||||
Frames.build(
|
||||
channel, encrypted, framePayload, messageType, frameType,
|
||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
||||
),
|
||||
)
|
||||
offset += size
|
||||
}
|
||||
splitMessages++
|
||||
}
|
||||
val framePayload = if (encrypted) tls.encrypt(chunk) else chunk
|
||||
output.write(
|
||||
Frames.build(
|
||||
channel, encrypted, framePayload, messageType, frameType,
|
||||
if (frameType == Frames.TYPE_FIRST) payload.size else null,
|
||||
),
|
||||
)
|
||||
offset += size
|
||||
output.flush()
|
||||
} catch (e: java.io.IOException) {
|
||||
if (running) log.w("send failed, stopping: ${e.message}")
|
||||
running = false
|
||||
videoSource?.stop()
|
||||
}
|
||||
splitMessages++
|
||||
}
|
||||
output.flush()
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
// The session is over; nothing more goes out.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,9 +1130,13 @@ class GearslipRunner(
|
||||
val sent = framesSent
|
||||
val fps = (sent - lastFrames) / 5.0
|
||||
lastFrames = sent
|
||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent dropped=$dropped acked=$acksSeen")
|
||||
// Time the encoder spent held back waiting for acks: near 5000ms means the
|
||||
// head unit's acks, not the phone, set the frame rate.
|
||||
val waited = ackWaitMs
|
||||
ackWaitMs = 0
|
||||
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent acked=$acksSeen")
|
||||
log.i(
|
||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
|
||||
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent waited=${waited}ms " +
|
||||
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
|
||||
)
|
||||
|
||||
@@ -787,6 +1154,13 @@ class GearslipRunner(
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* Force the protocol version Gearslip claims in VersionResponse, as (major, minor), or
|
||||
* null to echo the head unit. 1.3 is what the 2018 Uconnect (which accepts the JVC cert)
|
||||
* speaks. This is the 2025 identity-check downgrade experiment: see onVersionRequest.
|
||||
*/
|
||||
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
|
||||
|
||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||
const val MSG_VERSION_REQUEST = 1
|
||||
const val MSG_VERSION_RESPONSE = 2
|
||||
@@ -796,6 +1170,8 @@ class GearslipRunner(
|
||||
const val MSG_SERVICE_DISCOVERY_RESPONSE = 6
|
||||
const val MSG_PING_REQUEST = 11
|
||||
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_RESPONSE = 8
|
||||
|
||||
@@ -810,13 +1186,30 @@ class GearslipRunner(
|
||||
const val MSG_VIDEO_FOCUS_REQUEST = 32775
|
||||
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.
|
||||
// aasdk SensorMessageId: REQUEST, RESPONSE, BATCH, ERROR.
|
||||
const val MSG_SENSOR_START_REQUEST = 32769
|
||||
const val MSG_SENSOR_START_RESPONSE = 32770
|
||||
const val MSG_SENSOR_EVENT_INDICATION = 32771
|
||||
const val MSG_SENSOR_ERROR = 32772
|
||||
private const val SENSOR_LOCATION = 1
|
||||
private const val SENSOR_NIGHT = 10
|
||||
private const val SENSOR_LOG_EVERY_MS = 10_000L
|
||||
|
||||
const val STREAM_MEDIA = 3
|
||||
const val CODEC_H264_BP = 3
|
||||
const val VIDEO_FOCUS_PROJECTED = 1
|
||||
const val VIDEO_FOCUS_NATIVE = 2
|
||||
const val VIDEO_FOCUS_PROJECTED_NO_INPUT = 4
|
||||
const val VIDEO_FOCUS_REASON_LAUNCH_NATIVE = 2
|
||||
const val SESSION_ID = 1
|
||||
const val 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_UP = 1
|
||||
|
||||
const val ACK_STALL_MS = 2_000L
|
||||
const val AUDIO_TRACE_MESSAGES = 6
|
||||
const val PERIODIC_KEYFRAME_MS = 5_000L
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.RSAPrivateCrtKeySpec
|
||||
import java.security.spec.RSAPublicKeySpec
|
||||
|
||||
/**
|
||||
* Converts a PEM-encoded X.509 certificate and PEM-encoded RSA PKCS#1 private key into a
|
||||
* PKCS#12 [KeyStore], using only standard JCA APIs — no BouncyCastle.
|
||||
*
|
||||
* The DER parsing of the PKCS#1 key is done by hand (matching the same approach used in
|
||||
* [SelfSignedCert] for writing). Android's Conscrypt [KeyFactory] only accepts PKCS#8,
|
||||
* so we translate PKCS#1 → [RSAPrivateCrtKeySpec] ourselves.
|
||||
*/
|
||||
object PemKeyParser {
|
||||
|
||||
private const val CERT_HEADER = "-----BEGIN CERTIFICATE-----"
|
||||
private const val CERT_FOOTER = "-----END CERTIFICATE-----"
|
||||
private const val KEY_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
|
||||
private const val KEY_FOOTER = "-----END RSA PRIVATE KEY-----"
|
||||
|
||||
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
|
||||
|
||||
/**
|
||||
* Parses PEM strings for the certificate and private key, verifies they match, and
|
||||
* returns a PKCS#12 [KeyStore] ready for [CertProvider] to consume.
|
||||
*/
|
||||
fun build(certificatePem: String, privateKeyPem: String, alias: String = "phone"): Result {
|
||||
val cert = parseCertificate(certificatePem)
|
||||
val keySpec = parsePrivateKey(privateKeyPem)
|
||||
|
||||
val kf = KeyFactory.getInstance("RSA")
|
||||
val pubSpec = RSAPublicKeySpec(keySpec.spec.modulus, keySpec.spec.publicExponent)
|
||||
require(kf.generatePublic(pubSpec) == cert.publicKey) {
|
||||
"the certificate's public key does not match the private key"
|
||||
}
|
||||
|
||||
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||
load(null, null)
|
||||
setKeyEntry(alias, keySpec.getPrivate(), CharArray(0), arrayOf(cert))
|
||||
}
|
||||
return Result(keyStore, cert)
|
||||
}
|
||||
|
||||
/** Strips PEM headers/footers and decodes the base64 body. */
|
||||
fun parseCertificate(pem: String): X509Certificate {
|
||||
val der = pemToDer(pem, CERT_HEADER, CERT_FOOTER)
|
||||
return CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||
}
|
||||
|
||||
/** Parses an RSA PKCS#1 private key (ASN.1 DER) into a key spec. */
|
||||
fun parsePrivateKey(pem: String): KeySpec {
|
||||
val der = pemToDer(pem, KEY_HEADER, KEY_FOOTER)
|
||||
val integers = readPkcs1Sequence(der)
|
||||
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||
val spec = RSAPrivateCrtKeySpec(integers[1], integers[2], integers[3], integers[4], integers[5], integers[6], integers[7], integers[8])
|
||||
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec)
|
||||
return KeySpec(spec, privateKey)
|
||||
}
|
||||
|
||||
/** Holds both the spec and the constructed key for matching against a certificate's public key. */
|
||||
class KeySpec(val spec: RSAPrivateCrtKeySpec, private val privateKey: java.security.PrivateKey) {
|
||||
fun getPrivate() = privateKey
|
||||
}
|
||||
|
||||
/** Strips headers and decodes base64. */
|
||||
private fun pemToDer(pem: String, header: String, footer: String): ByteArray {
|
||||
val start = pem.indexOf(header)
|
||||
val end = pem.indexOf(footer)
|
||||
if (start == -1 || end == -1 || end <= start) {
|
||||
throw IllegalArgumentException("not a valid PEM $header block")
|
||||
}
|
||||
val b64 = pem.substring(start + header.length, end)
|
||||
.replace(Regex("\\s+"), "")
|
||||
return java.util.Base64.getDecoder().decode(b64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the inner SEQUENCE of an RSA PKCS#1 DER blob into a list of [BigInteger]s.
|
||||
* Throws if the ASN.1 structure is wrong, so caller gets a clear error rather than a
|
||||
* silent wrong key.
|
||||
*/
|
||||
private fun readPkcs1Sequence(der: ByteArray): List<BigInteger> {
|
||||
var pos = 0
|
||||
fun readTag(): Int {
|
||||
require(pos < der.size) { "unexpected end of DER at tag" }
|
||||
return der[pos++].toInt() and 0xFF
|
||||
}
|
||||
fun readLength(): Int {
|
||||
require(pos < der.size) { "unexpected end of DER at length" }
|
||||
val b0 = der[pos++].toInt() and 0xFF
|
||||
if (b0 < 0x80) return b0
|
||||
val numBytes = b0 and 0x7F
|
||||
require(pos + numBytes <= der.size) { "truncated long-form length" }
|
||||
var result = 0
|
||||
repeat(numBytes) {
|
||||
result = (result shl 8) or (der[pos++].toInt() and 0xFF)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val outerTag = readTag()
|
||||
require(outerTag == 0x30) { "expected SEQUENCE (0x30), got 0x${outerTag.toString(16)}" }
|
||||
val outerLen = readLength()
|
||||
require(pos + outerLen <= der.size) { "SEQUENCE length $outerLen exceeds remaining data" }
|
||||
|
||||
val integers = mutableListOf<BigInteger>()
|
||||
while (pos < der.size) {
|
||||
val tag = readTag()
|
||||
require(tag == 0x02) { "expected INTEGER (0x02), got 0x${tag.toString(16)} at offset ${pos - 1}" }
|
||||
val len = readLength()
|
||||
require(pos + len <= der.size) { "INTEGER length $len exceeds remaining data" }
|
||||
integers.add(BigInteger(der.copyOfRange(pos, pos + len)))
|
||||
pos += len
|
||||
}
|
||||
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||
return integers
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@ object Protobuf {
|
||||
varint((field shl 3).toLong()) + varint(value)
|
||||
|
||||
/** 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
|
||||
while (pos < data.size) {
|
||||
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
||||
@@ -45,7 +48,7 @@ object Protobuf {
|
||||
0 -> {
|
||||
val (value, afterValue) = readVarint(data, pos) ?: return null
|
||||
pos = afterValue
|
||||
if (number == field) return value.toInt()
|
||||
if (number == field) return value
|
||||
}
|
||||
2 -> {
|
||||
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. */
|
||||
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()
|
||||
|
||||
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||
@@ -57,7 +69,7 @@ class ScreenProjector(private val context: Context) {
|
||||
// driver on every connect.
|
||||
val display = try {
|
||||
displayManager.createVirtualDisplay(
|
||||
"gearslip-projection", width, height, densityDpi, surface,
|
||||
name, width, height, densityDpi, surface,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
runCatching { presentation?.dismiss() }
|
||||
runCatching { owner?.destroy() }
|
||||
|
||||
@@ -20,8 +20,8 @@ object ServiceDiscovery {
|
||||
|
||||
/**
|
||||
* [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
|
||||
* vehicle profile, but not applied automatically - what they mean varies by unit.
|
||||
* and 4): pixels of the frame it crops away, split evenly between the two edges, to fit the
|
||||
* frame to a screen of a different shape. A matched vehicle profile's insets override them.
|
||||
*/
|
||||
class VideoConfig(
|
||||
val resolution: Int,
|
||||
@@ -38,11 +38,21 @@ object ServiceDiscovery {
|
||||
|
||||
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() =
|
||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
||||
}
|
||||
|
||||
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>) {
|
||||
/** [displayId] is MediaSinkService field 6: 0, the default, is the main screen. */
|
||||
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>, val displayId: Int = 0) {
|
||||
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
||||
}
|
||||
|
||||
@@ -89,6 +99,48 @@ object ServiceDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The media sink to stream to. Gearslip sends raw PCM, so a PCM sink wins: the 2022 Dacia
|
||||
* Jogger (LGE ULC 4.5) lists an AAC media sink on channel 3 before a PCM one on channel 5,
|
||||
* and PCM pushed into the AAC sink was acked but played as silence. With no PCM sink, the
|
||||
* first media sink is the best there is.
|
||||
*/
|
||||
fun pickMediaSink(sinks: List<AudioService>): AudioService? {
|
||||
val media = sinks.filter { it.streamType == STREAM_MEDIA }
|
||||
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's microphone, from MediaSourceService (Service field 5):
|
||||
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
|
||||
* bool available_while_in_call = 3; }
|
||||
* Null when the head unit has none.
|
||||
*/
|
||||
fun findMicService(response: ByteArray): AudioService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
|
||||
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
|
||||
return AudioService(
|
||||
serviceId = id,
|
||||
streamType = 0,
|
||||
codecType = Wire.varint(source, 1)?.toInt() ?: CODEC_PCM,
|
||||
configs = listOf(
|
||||
AudioConfig(
|
||||
sampleRate = Wire.varint(f, 1)?.toInt() ?: 16_000,
|
||||
bits = Wire.varint(f, 2)?.toInt() ?: 16,
|
||||
channels = Wire.varint(f, 3)?.toInt() ?: 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const val STREAM_MEDIA = 3
|
||||
const val CODEC_PCM = 1
|
||||
|
||||
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
|
||||
|
||||
private val RESOLUTIONS = mapOf(
|
||||
@@ -106,30 +158,83 @@ object ServiceDiscovery {
|
||||
)
|
||||
|
||||
/**
|
||||
* The head unit's touchscreen, from InputSourceService (Service field 4):
|
||||
* InputSourceService { keycodes = 1; repeated TouchScreen touchscreen = 2; ... }
|
||||
* The head unit's touchscreen and button set, from InputSourceService (Service field 4):
|
||||
* InputSourceService { repeated int32 keycodes_supported = 1 [packed=true];
|
||||
* repeated TouchScreen touchscreen = 2; ... }
|
||||
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
|
||||
*
|
||||
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
|
||||
* scaled to the projected video size before injection.
|
||||
* 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(),
|
||||
/** InputSourceService field 5: which screen this input belongs to, 0 for the main one. */
|
||||
val displayId: Int = 0,
|
||||
)
|
||||
|
||||
fun findInputService(response: ByteArray): InputService? {
|
||||
/**
|
||||
* The input for the screen Gearslip projects to ([displayId]). A head unit with two screens
|
||||
* offers an input service for each: LIVI lists an empty one for its second screen first,
|
||||
* and taking that one left every touch on the real touchscreen ignored.
|
||||
*/
|
||||
fun findInputService(response: ByteArray, displayId: Int = 0): InputService? {
|
||||
val inputs = inputServices(response)
|
||||
return inputs.firstOrNull { it.displayId == displayId }
|
||||
?: inputs.firstOrNull { it.touchWidth > 0 && it.touchHeight > 0 }
|
||||
?: inputs.firstOrNull()
|
||||
}
|
||||
|
||||
private fun inputServices(response: ByteArray): List<InputService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val input = Wire.bytes(service, 4) ?: continue
|
||||
val touch = Wire.bytes(Wire.fields(input), 2) ?: continue
|
||||
val touchFields = Wire.fields(touch)
|
||||
val width = Wire.varint(touchFields, 1)?.toInt() ?: continue
|
||||
val height = Wire.varint(touchFields, 2)?.toInt() ?: continue
|
||||
return InputService(id, width, height)
|
||||
val inputFields = Wire.fields(input)
|
||||
// A unit with only a knob and buttons has no touchscreen entry; it still has keys.
|
||||
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
|
||||
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
|
||||
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
|
||||
// Declared packed, but some units send each keycode as its own field.
|
||||
val keycodes = inputFields.filter { it.number == 1 }.flatMap { f ->
|
||||
f.bytes?.let(::packedVarints) ?: listOf(f.varint.toInt())
|
||||
}
|
||||
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Decodes a `[packed=true]` repeated varint field's raw bytes into individual values. */
|
||||
private fun packedVarints(data: ByteArray): List<Int> = buildList {
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
while (pos < data.size) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
pos++
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
add(result.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main screen's video sink. A head unit with a second screen, such as LIVI with a
|
||||
* cluster display, offers a sink for each; the one without a display id (0) is the main one.
|
||||
*/
|
||||
fun findVideoService(response: ByteArray): VideoService? {
|
||||
val sinks = videoServices(response)
|
||||
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
|
||||
}
|
||||
|
||||
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
@@ -150,7 +255,31 @@ object ServiceDiscovery {
|
||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
return VideoService(id, codecType, configs)
|
||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The head unit's sensor feed, from SensorSourceService (Service field 2; field 5 is the car's mic):
|
||||
* SensorSourceService { repeated Sensor sensors = 1; }
|
||||
* Sensor { required SensorType sensor_type = 1; }
|
||||
*
|
||||
* Field numbers for what actually rides inside each SensorEvent once subscribed are not
|
||||
* pinned down here - unlike video/audio/input, there's no independent source to check them
|
||||
* against, so [CarSensors] shows the raw decode instead of trusting a guessed field map.
|
||||
*/
|
||||
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, 2) ?: 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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,7 @@ object SessionReport {
|
||||
AUTH("authentication failed"),
|
||||
SERVICE_DISCOVERY("service discovery failed"),
|
||||
VIDEO("video stream failed"),
|
||||
BYEBYE("head unit ended the session"),
|
||||
CRASH("app crash"),
|
||||
UNKNOWN("unclassified"),
|
||||
}
|
||||
@@ -47,7 +49,10 @@ object SessionReport {
|
||||
@Volatile private var lastState = ""
|
||||
private var printed = false
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||
appVersion = info?.versionName ?: "?"
|
||||
appCode = info?.longVersionCode ?: 0
|
||||
@@ -58,6 +63,8 @@ object SessionReport {
|
||||
category = Category.NONE
|
||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||
headUnitInfo = null
|
||||
carScreen = ""; carDpi = 0
|
||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||
printed = false
|
||||
}
|
||||
@@ -67,6 +74,14 @@ object SessionReport {
|
||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||
fun auth(status: Int) { authStatus = status }
|
||||
fun headUnit(info: String) { headUnit = info }
|
||||
|
||||
/** Who the car said it is, for the opt-in usage note; null until service discovery. */
|
||||
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
|
||||
|
||||
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
|
||||
@Volatile var carScreen = ""; private set
|
||||
@Volatile var carDpi = 0; private set
|
||||
fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi }
|
||||
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||
fun video(summary: String) { video = summary }
|
||||
fun audio(summary: String) { audio = summary }
|
||||
@@ -120,6 +135,18 @@ object SessionReport {
|
||||
if (printed || startedAt == 0L) return
|
||||
printed = true
|
||||
render().lines().forEach { GearslipLog.i(it) }
|
||||
appContext?.let { context ->
|
||||
// Video started means the car showed Gearslip: that's a working car, and unplugging or
|
||||
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
|
||||
val reached = carScreen.isNotEmpty()
|
||||
val outcome = when {
|
||||
reached && category in setOf(Category.NONE, Category.USB, Category.BYEBYE) -> "connected"
|
||||
reached -> "connected, then ${category.label}"
|
||||
category == Category.NONE -> "ended before video"
|
||||
else -> category.label
|
||||
}
|
||||
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) }
|
||||
}
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ class VideoSource(
|
||||
private var frameIndex = 0L
|
||||
|
||||
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(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
if (mode == Mode.SURFACE) {
|
||||
@@ -54,21 +55,50 @@ class VideoSource(
|
||||
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_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)
|
||||
if (mode == Mode.SURFACE) {
|
||||
// A surface encoder only makes a frame when the screen changes, and many decoders
|
||||
// show a frame only once the next one arrives. So the last change before the
|
||||
// screen goes still (a menu closing) sat in the car's decoder until something
|
||||
// else moved. Repeating the last frame after a short pause pushes it through;
|
||||
// a repeat of an unchanged screen costs a few bytes. scrcpy does the same.
|
||||
setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_AFTER_US)
|
||||
// An app that draws nonstop (a moving map) renders at the phone's refresh rate,
|
||||
// faster than the car acks. Every extra frame queued inside the encoder, and the
|
||||
// car's screen fell 25 seconds behind. Capping the input drops the extras before
|
||||
// they're encoded, so the car always gets the newest frame.
|
||||
setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat())
|
||||
}
|
||||
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||
setInteger(MediaFormat.KEY_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)
|
||||
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
var encoder = MediaCodec.createEncoderByType(MIME)
|
||||
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()
|
||||
encoder.start()
|
||||
codec = encoder
|
||||
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 {
|
||||
runCatching { pump(encoder) }
|
||||
@@ -123,7 +153,10 @@ class VideoSource(
|
||||
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) {
|
||||
val buffer = encoder.getOutputBuffer(outIndex)
|
||||
if (buffer != null && info.size > 0) {
|
||||
@@ -144,7 +177,7 @@ class VideoSource(
|
||||
outIndex = encoder.dequeueOutputBuffer(info, 0)
|
||||
}
|
||||
|
||||
Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
|
||||
if (mode != Mode.SURFACE) Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +221,14 @@ class VideoSource(
|
||||
private companion object {
|
||||
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
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
|
||||
private const val REPEAT_AFTER_US = 100_000L
|
||||
|
||||
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
|
||||
val BAR_COLOURS = listOf(
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
@@ -83,7 +84,13 @@ class AudioCaptureService : Service() {
|
||||
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)
|
||||
projection = try {
|
||||
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
|
||||
@@ -129,7 +136,7 @@ class AudioCaptureService : Service() {
|
||||
AudioRecord.Builder()
|
||||
.setAudioPlaybackCaptureConfig(config)
|
||||
.setAudioFormat(format)
|
||||
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * 4))
|
||||
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * BUFFER_CHUNKS))
|
||||
.build()
|
||||
} catch (t: Throwable) {
|
||||
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. */
|
||||
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 SILENCE_MS = 5_000L
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class AudioLink(
|
||||
private var bytes = 0L
|
||||
private var dropped = 0
|
||||
@Volatile private var configTimer: Thread? = null
|
||||
private val ackLock = Object()
|
||||
|
||||
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
|
||||
override val sampleRate get() = config?.sampleRate ?: 48_000
|
||||
@@ -115,8 +116,11 @@ class AudioLink(
|
||||
|
||||
MSG_ACK -> {
|
||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
synchronized(ackLock) {
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the head unit answers Setup with, we need its bytes to identify it.
|
||||
@@ -166,6 +170,7 @@ class AudioLink(
|
||||
override fun stop() {
|
||||
if (!started) return
|
||||
started = false
|
||||
synchronized(ackLock) { ackLock.notifyAll() }
|
||||
runCatching {
|
||||
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.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) {
|
||||
if (!started) return
|
||||
if (inFlight >= maxUnacked) {
|
||||
// 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")
|
||||
inFlight = 0
|
||||
} else {
|
||||
dropped++
|
||||
return
|
||||
// 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.
|
||||
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window")
|
||||
inFlight = 0
|
||||
break
|
||||
}
|
||||
ackLock.wait(STALL_MS - waited + 1)
|
||||
}
|
||||
}
|
||||
if (!started) {
|
||||
dropped++
|
||||
return
|
||||
}
|
||||
val timestamp = ByteArray(8)
|
||||
val micros = System.nanoTime() / 1_000
|
||||
for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||
@@ -199,7 +212,7 @@ class AudioLink(
|
||||
started = false
|
||||
return
|
||||
}
|
||||
inFlight++
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
chunks++
|
||||
bytes += size
|
||||
// The first chunk proves the whole path; after that, an occasional line is enough to show
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package app.seb3thehacker.gearslip.audio
|
||||
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The car's own microphone, which most head units offer over the same link as the screen:
|
||||
*
|
||||
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||
* -> Setup { config_index } <- Config
|
||||
* -> MicrophoneRequest { open } <- MicrophoneResponse { status, session_id }
|
||||
* <- Data { 8-byte timestamp, PCM }... -> Ack { session_id, ack }
|
||||
* -> MicrophoneRequest { open = false }
|
||||
*
|
||||
* The channel opens with the session, but the car only records between [start] and [stop].
|
||||
* Speech wants 16 kHz mono; a car that records at a multiple of that is averaged down to it, and
|
||||
* one that doesn't isn't used, so voice falls back to the phone's microphone.
|
||||
*/
|
||||
class CarMic(
|
||||
private val service: ServiceDiscovery.AudioService,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
private val config = service.configs.first()
|
||||
private val step = config.sampleRate / RATE
|
||||
private val usable = service.codecType == ServiceDiscovery.CODEC_PCM && config.bits == 16 &&
|
||||
config.sampleRate % RATE == 0 && step >= 1 && config.channels in 1..2
|
||||
|
||||
@Volatile private var ready = false
|
||||
@Volatile private var listening = false
|
||||
@Volatile private var session = 0
|
||||
private var packets = 0
|
||||
private val chunks = LinkedBlockingQueue<ShortArray>()
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
GearslipLog.i("mic: <- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) return
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
|
||||
ready = usable
|
||||
if (!usable) {
|
||||
GearslipLog.w(
|
||||
"mic: the car records ${service.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}, " +
|
||||
"which speech can't use - voice stays on the phone's microphone",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MSG_CONFIG -> GearslipLog.i("mic: <- Config status=${Wire.varint(Wire.fields(body), 1)}")
|
||||
|
||||
MSG_MIC_RESPONSE -> {
|
||||
val fields = Wire.fields(body)
|
||||
session = Wire.varint(fields, 2)?.toInt() ?: 0
|
||||
GearslipLog.i("mic: <- MicrophoneResponse status=${Wire.varint(fields, 1)} session=$session")
|
||||
}
|
||||
|
||||
MSG_DATA -> {
|
||||
sendOnChannel(MSG_ACK, Protobuf.varintField(1, session.toLong()) + Protobuf.varintField(2, 1L))
|
||||
if (!listening || body.size <= TIMESTAMP_BYTES) return
|
||||
if (packets++ == 0) GearslipLog.i("mic: first audio from the car (${body.size - TIMESTAMP_BYTES} bytes)")
|
||||
chunks.offer(toSpeechRate(body))
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("mic: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
/** Little-endian PCM after the timestamp, mixed to mono and averaged down to [RATE]. */
|
||||
private fun toSpeechRate(body: ByteArray): ShortArray {
|
||||
val frameBytes = 2 * config.channels
|
||||
val frames = (body.size - TIMESTAMP_BYTES) / frameBytes
|
||||
val out = ShortArray(frames / step)
|
||||
for (i in out.indices) {
|
||||
var sum = 0
|
||||
for (s in 0 until step) {
|
||||
val at = TIMESTAMP_BYTES + (i * step + s) * frameBytes
|
||||
for (c in 0 until config.channels) {
|
||||
val o = at + 2 * c
|
||||
sum += (body[o].toInt() and 0xFF) or (body[o + 1].toInt() shl 8)
|
||||
}
|
||||
}
|
||||
out[i] = (sum / (step * config.channels)).toShort()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Asks the car to start recording. False when it can't, so the phone's microphone is used. */
|
||||
fun start(): Boolean {
|
||||
if (!ready) return false
|
||||
chunks.clear()
|
||||
packets = 0
|
||||
listening = true
|
||||
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 1L) + Protobuf.varintField(4, 1L))
|
||||
GearslipLog.i("mic: -> MicrophoneRequest(open)")
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next piece of audio, or null if the car sent nothing for [timeoutMs]. */
|
||||
fun read(timeoutMs: Long): ShortArray? = chunks.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
fun stop() {
|
||||
if (!listening) return
|
||||
listening = false
|
||||
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 0L))
|
||||
GearslipLog.i("mic: -> MicrophoneRequest(close) after $packets packets")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The connected car's microphone, or null with no car or one that has none. */
|
||||
@Volatile var current: CarMic? = null
|
||||
|
||||
const val RATE = 16_000
|
||||
private const val TIMESTAMP_BYTES = 8
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_DATA = 0
|
||||
private const val MSG_SETUP = 32768
|
||||
private const val MSG_CONFIG = 32771
|
||||
private const val MSG_ACK = 32772
|
||||
private const val MSG_MIC_REQUEST = 32773
|
||||
private const val MSG_MIC_RESPONSE = 32774
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.ContactsContract
|
||||
import android.telecom.TelecomManager
|
||||
import android.telephony.TelephonyCallback
|
||||
import android.telephony.TelephonyManager
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
|
||||
data class Caller(val number: String, val name: String?) {
|
||||
val label: String get() = name ?: number.ifBlank { "Unknown number" }
|
||||
}
|
||||
|
||||
sealed interface CallUiState {
|
||||
data object None : CallUiState
|
||||
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
|
||||
data class Active(val caller: Caller?) : CallUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the phone's call state for the car screen, and answers or declines through the two
|
||||
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
|
||||
* and [GearslipCallScreening]'s response to decline one that is still ringing.
|
||||
*
|
||||
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
|
||||
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
|
||||
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
|
||||
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
|
||||
* only ever carries media playback.
|
||||
*/
|
||||
object CarCalls {
|
||||
|
||||
private val _state = MutableStateFlow<CallUiState>(CallUiState.None)
|
||||
val state: StateFlow<CallUiState> = _state.asStateFlow()
|
||||
|
||||
private var telephony: TelephonyManager? = null
|
||||
private var watcher: TelephonyCallback? = null
|
||||
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
|
||||
private var ringingCaller: Caller? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
if (watcher != null) return
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
|
||||
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||
override fun onCallStateChanged(state: Int) = onPhoneState(state)
|
||||
}
|
||||
runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
|
||||
.onSuccess { telephony = manager; watcher = callback }
|
||||
.onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val callback = watcher ?: return
|
||||
runCatching { telephony?.unregisterTelephonyCallback(callback) }
|
||||
telephony = null
|
||||
watcher = null
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
|
||||
private fun onPhoneState(state: Int) {
|
||||
when (state) {
|
||||
TelephonyManager.CALL_STATE_IDLE -> {
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||
pendingRespond = null
|
||||
_state.value = CallUiState.Active(ringingCaller)
|
||||
}
|
||||
TelephonyManager.CALL_STATE_RINGING -> {
|
||||
// The number itself only ever arrives through the screening service; if this
|
||||
// fires first, the ringing card appears once incomingCall() catches up.
|
||||
ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
|
||||
fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
|
||||
val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
|
||||
ringingCaller = caller
|
||||
pendingRespond = respond
|
||||
_state.value = CallUiState.Ringing(caller, canDecline = true)
|
||||
}
|
||||
|
||||
/** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
|
||||
fun screeningExpired() {
|
||||
pendingRespond = null
|
||||
val caller = ringingCaller ?: return
|
||||
if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
|
||||
}
|
||||
|
||||
fun decline() {
|
||||
val respond = pendingRespond ?: return
|
||||
pendingRespond = null
|
||||
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun answer(context: Context) {
|
||||
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
|
||||
return
|
||||
}
|
||||
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
|
||||
runCatching { telecom.acceptRingingCall() }
|
||||
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun lookUpName(context: Context, number: String): String? {
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
|
||||
return runCatching {
|
||||
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
|
||||
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
|
||||
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
|
||||
Handler(Looper.getMainLooper()).postDelayed(action, ms)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.telecom.Call
|
||||
import android.telecom.CallScreeningService
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
|
||||
/**
|
||||
* Sees every incoming call before it rings, so the car screen can show who is calling and
|
||||
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
|
||||
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
|
||||
* for, not a privileged permission.
|
||||
*
|
||||
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
|
||||
* response is sent either when the driver taps a button or when that window is about to close,
|
||||
* whichever comes first. Answering a call that has already passed this point still works, since
|
||||
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
|
||||
*/
|
||||
class GearslipCallScreening : CallScreeningService() {
|
||||
|
||||
override fun onScreenCall(callDetails: Call.Details) {
|
||||
val number = callDetails.handle?.schemeSpecificPart
|
||||
var responded = false
|
||||
|
||||
val respond: (Boolean) -> Unit = { allow ->
|
||||
if (!responded) {
|
||||
responded = true
|
||||
runCatching {
|
||||
respondToCall(
|
||||
callDetails,
|
||||
CallResponse.Builder()
|
||||
.setDisallowCall(!allow)
|
||||
.setRejectCall(!allow)
|
||||
.setSkipNotification(false)
|
||||
.build(),
|
||||
)
|
||||
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
CarCalls.incomingCall(applicationContext, number, respond)
|
||||
|
||||
// Answer for the system if nobody has by the time its own patience runs out, so a call
|
||||
// Gearslip is slow to show never gets silently blocked.
|
||||
mainThreadDelay(SCREENING_WINDOW_MS) {
|
||||
respond(true)
|
||||
CarCalls.screeningExpired()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCREENING_WINDOW_MS = 3_500L
|
||||
}
|
||||
}
|
||||
@@ -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,89 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Draws the raw video frame with pixel rulers, for measuring what the head unit actually shows.
|
||||
*
|
||||
* Numbers are frame pixels. Read the last number visible on each edge to see how much the head
|
||||
* unit crops there (the frame is 800 wide by 480 tall when the ruler runs to those values), and
|
||||
* tap anywhere: the crosshair is drawn where the phone believes the touch landed, so a mismatch
|
||||
* with your finger is the touch offset. Ignores vehicle insets on purpose.
|
||||
*/
|
||||
@Composable
|
||||
fun CalibrationApp() {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
var touch by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
touch = down.position
|
||||
}
|
||||
},
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val label = TextStyle(color = Color.White, fontSize = 11.sp)
|
||||
val faint = Color(0x55FFFFFF)
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
|
||||
var y = 0
|
||||
while (y <= h) {
|
||||
drawLine(if (y % 100 == 0) Color.White else faint, Offset(0f, y.toFloat()), Offset(w, y.toFloat()), strokeWidth = 1f)
|
||||
if (y % 20 == 0) {
|
||||
drawText(measurer, "$y", Offset(2f, y.toFloat()), style = label)
|
||||
drawText(measurer, "$y", Offset(w - 30f, y.toFloat()), style = label)
|
||||
}
|
||||
y += 20
|
||||
}
|
||||
var x = 0
|
||||
while (x <= w) {
|
||||
drawLine(if (x % 100 == 0) Color.White else faint, Offset(x.toFloat(), 0f), Offset(x.toFloat(), h), strokeWidth = 1f)
|
||||
if (x % 50 == 0 && x > 0) {
|
||||
drawText(measurer, "$x", Offset(x.toFloat() + 2f, 2f), style = label)
|
||||
drawText(measurer, "$x", Offset(x.toFloat() + 2f, h - 46f), style = label)
|
||||
}
|
||||
x += 20
|
||||
}
|
||||
// Frame border, drawn last and inset by 1px so it is the outermost thing that can show.
|
||||
drawRect(Color.Red, topLeft = Offset(1f, 1f), size = androidx.compose.ui.geometry.Size(w - 2f, h - 2f), style = androidx.compose.ui.graphics.drawscope.Stroke(2f))
|
||||
drawText(measurer, "frame ${w.toInt()}x${h.toInt()}", Offset(w / 2 - 50f, h / 2 - 60f), style = TextStyle(color = Color.Yellow, fontSize = 14.sp))
|
||||
|
||||
touch?.let { t ->
|
||||
drawLine(Color.Green, Offset(t.x, 0f), Offset(t.x, h), strokeWidth = 2f)
|
||||
drawLine(Color.Green, Offset(0f, t.y), Offset(w, t.y), strokeWidth = 2f)
|
||||
drawText(measurer, "touch ${t.x.toInt()},${t.y.toInt()}", Offset(w / 2 - 50f, h / 2 + 40f), style = TextStyle(color = Color.Green, fontSize = 14.sp))
|
||||
}
|
||||
}
|
||||
Button(onClick = { navigator.home() }, modifier = Modifier.align(Alignment.Center)) {
|
||||
Text("Exit calibration")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
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.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
|
||||
/**
|
||||
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
|
||||
* whole screen, because nothing else matters more right now; an active call is a thin band along
|
||||
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
|
||||
*/
|
||||
@Composable
|
||||
fun CallOverlay(modifier: Modifier = Modifier) {
|
||||
val state by CarCalls.state.collectAsState()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
when (val s = state) {
|
||||
// Covers the box regardless of where the caller aligned it, so the alignment below is
|
||||
// only ever felt by the active-call banner, which is the one that isn't full-screen.
|
||||
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
|
||||
is CallUiState.Active -> ActiveCallBanner(s, modifier)
|
||||
CallUiState.None -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline: () -> Unit) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLowest) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Call,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Incoming call",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
s.caller.label,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
||||
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
||||
}
|
||||
if (!s.canDecline) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
"Already ringing out loud - answer here, or let it ring.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
GsIconButton(
|
||||
icon, label, onClick,
|
||||
colors = GsColors(color, Color.White),
|
||||
enabled = enabled,
|
||||
size = 72.dp,
|
||||
iconSize = 30.dp,
|
||||
shape = CircleShape,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.height(0.dp))
|
||||
Text(
|
||||
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
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.Column
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -60,7 +66,6 @@ fun CarAppStage(
|
||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
modifier = modifier
|
||||
.pointerInput(connection) {
|
||||
@@ -102,6 +130,30 @@ private fun AppSurface(
|
||||
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
|
||||
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 ->
|
||||
SurfaceView(context).apply {
|
||||
@@ -115,20 +167,20 @@ private fun AppSurface(
|
||||
height: Int,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
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
|
||||
@@ -158,34 +210,13 @@ private fun ChromeBar(
|
||||
}.joinToString(" - ")
|
||||
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. */
|
||||
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
|
||||
@@ -1,7 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -28,8 +28,8 @@ object CarApps {
|
||||
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
||||
PhoneAppsScreen()
|
||||
},
|
||||
CarApp("calibrate", "Display test", Icons.Filled.Info) {
|
||||
CalibrationApp()
|
||||
CarApp("dialer", "Phone", Icons.Filled.Call) {
|
||||
PhoneDialerScreen()
|
||||
},
|
||||
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
||||
CarSettingsScreen()
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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 app.seb3thehacker.gearslip.speech.VoiceFocus
|
||||
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
|
||||
}
|
||||
appContext?.let(VoiceFocus::acquire)
|
||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) {
|
||||
VoiceFocus.release()
|
||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||
}
|
||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||
override fun onError(utteranceId: String?) {
|
||||
VoiceFocus.release()
|
||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||
}
|
||||
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
|
||||
})
|
||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
val context = appContext
|
||||
if (engine == null || context == null || !ttsReady || text.isBlank()) {
|
||||
mainHandler.post(onDone)
|
||||
return
|
||||
}
|
||||
VoiceFocus.acquire(context)
|
||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
|
||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||
override fun onError(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
|
||||
// Stopped part way: whoever stopped it has already moved on.
|
||||
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
|
||||
})
|
||||
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.collectAsState
|
||||
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.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -43,7 +44,8 @@ import kotlinx.coroutines.withContext
|
||||
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
|
||||
* this just shows an empty state instead of asking again from inside the car UI.
|
||||
*/
|
||||
@@ -58,9 +60,10 @@ fun CarDashboardScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader("Today")
|
||||
WeatherCard(context)
|
||||
AgendaCard(context)
|
||||
NotificationsSection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +73,10 @@ private fun WeatherCard(context: Context) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
LaunchedEffect(Unit) { Weather.refresh(context) }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
GsIconBox(
|
||||
onClick = { navigator.weather() },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.clickable { navigator.weather() },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(20.dp),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -13,46 +12,63 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import android.media.session.PlaybackState
|
||||
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.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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import kotlinx.coroutines.delay
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The home screen: the map on the left, what is playing on the right.
|
||||
* 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. The player only
|
||||
* takes its share of the screen only while something is playing or paused; otherwise the map has all of it.
|
||||
* The map is whatever navigation app was used last, brought back on its own. The player sits in
|
||||
* the nav bar's pill until its dock button moves it here; the column closes itself back to a
|
||||
* full-width map once nothing is playing.
|
||||
*/
|
||||
@Composable
|
||||
fun CarHome() {
|
||||
@@ -65,21 +81,35 @@ fun CarHome() {
|
||||
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
||||
|
||||
val navigator = LocalCarNavigator.current
|
||||
val showPlayer = phase == CarMedia.Phase.READY && now.isActive && !navigator.playerMinimised
|
||||
// Once playback ends there is nothing left to minimise; the next song starts in place.
|
||||
LaunchedEffect(now.isActive) { if (!now.isActive) navigator.restorePlayer() }
|
||||
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(
|
||||
Modifier
|
||||
.weight(if (showPlayer) 2.1f else 1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
|
||||
.weight(if (splitScreen) 2.1f else 1f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (showPlayer) {
|
||||
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
|
||||
if (splitScreen) {
|
||||
val side = Modifier.weight(1f).fillMaxHeight()
|
||||
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,119 +128,131 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val hasLast = CarSettings.lastNav.value != null
|
||||
val hasNavApps by CarServices.hasNavApps.collectAsStateWithLifecycle()
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
when (status.phase) {
|
||||
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
||||
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
|
||||
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
|
||||
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) }
|
||||
TextButton(onClick = navigator::apps) { Text("Choose another app") }
|
||||
GsButton(onClick = navigator::apps, tone = GsTone.Tonal) { Text("Choose another app") }
|
||||
}
|
||||
else -> {
|
||||
else -> if (!hasNavApps) {
|
||||
// Nothing to auto-select and nothing to pick from Car apps either - this is the
|
||||
// only case worth a distinct screen, since every other IDLE case resolves itself.
|
||||
Text("No mapping apps found.", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
if (hasLast) "Starting the map…" else "No map app chosen yet.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
"Install a navigation app that supports Android Auto and it will start here on its own.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (!hasLast) {
|
||||
Text(
|
||||
"Open Car apps and connect a navigation app; it will start here next time.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
|
||||
}
|
||||
} else if (hasLast) {
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */
|
||||
/**
|
||||
* The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat
|
||||
* and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
||||
* twice. The X sends it back to the nav bar.
|
||||
*/
|
||||
@Composable
|
||||
private fun MiniPlayer(modifier: Modifier) {
|
||||
private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val media = CarServices.media
|
||||
val now by media.now.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
var position by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(now) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
val navigator = LocalCarNavigator.current
|
||||
Surface(
|
||||
onClick = { navigator.media() },
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(Modifier.size(128.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
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.padding(vertical = 4.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { media.previous() }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(MediaIcons.Previous, "Previous", Modifier.size(30.dp))
|
||||
}
|
||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(60.dp)) {
|
||||
Icon(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
Modifier.size(36.dp),
|
||||
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() },
|
||||
) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
GsIconButton(
|
||||
Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
|
||||
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(30.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))
|
||||
}
|
||||
TransportRow(now, media, skip = 48.dp, modifier = Modifier.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val compact = PaddingValues(horizontal = 8.dp)
|
||||
FilledTonalButton(
|
||||
onClick = { navigator.media(lyrics = true) },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = compact,
|
||||
) { Text("Lyrics", maxLines = 1, softWrap = false) }
|
||||
FilledTonalButton(
|
||||
onClick = navigator::minimisePlayer,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = compact,
|
||||
) { Text("Minimise", maxLines = 1, softWrap = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -23,8 +26,23 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceIn
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
/**
|
||||
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
||||
* 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.
|
||||
@@ -35,7 +53,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
* 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
|
||||
* 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
|
||||
fun CarKeyboard(
|
||||
@@ -45,48 +69,88 @@ fun CarKeyboard(
|
||||
modifier: Modifier = Modifier,
|
||||
submitIcon: Boolean = true,
|
||||
submitImage: ImageVector? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
) {
|
||||
var symbols by remember { mutableStateOf(false) }
|
||||
var shifted by remember { mutableStateOf(false) }
|
||||
|
||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||
DisposableEffect(Unit) {
|
||||
CarKeyboardVisibility.state.value = true
|
||||
onDispose { CarKeyboardVisibility.state.value = false }
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
|
||||
val latestChange by rememberUpdatedState(onTextChange)
|
||||
val latestSubmit by rememberUpdatedState(onSubmit)
|
||||
val latestDismiss by rememberUpdatedState(onDismiss)
|
||||
val phoneField = remember {
|
||||
PhoneTyping.open(text, { latestChange(it) }, { latestSubmit() }, onDismiss?.let { { latestDismiss?.invoke() } })
|
||||
}
|
||||
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
|
||||
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
|
||||
|
||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
|
||||
|
||||
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
rows.forEach { row ->
|
||||
Column(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
rows.dropLast(1).forEach { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
if (!symbols) {
|
||||
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.4f)) { shifted = !shifted }
|
||||
// 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(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
|
||||
if (onDismiss != null) {
|
||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,11 +158,11 @@ fun CarKeyboard(
|
||||
|
||||
@Composable
|
||||
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -116,12 +180,30 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
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) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -137,10 +219,12 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
|
||||
}
|
||||
|
||||
/**
|
||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
|
||||
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
|
||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
|
||||
* still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys.
|
||||
*/
|
||||
private val KEY_HEIGHT = 34.dp
|
||||
private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.075f).dp.coerceIn(36.dp, 52.dp)
|
||||
|
||||
private val LocalKeyHeight = compositionLocalOf { 36.dp }
|
||||
|
||||
private val LETTER_ROWS = listOf(
|
||||
"qwertyuiop".map { it.toString() },
|
||||
|
||||
@@ -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,6 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -13,17 +15,21 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
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.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.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
@@ -42,14 +48,20 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
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.CarAppCatalog
|
||||
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||
import app.seb3thehacker.gearslip.media.MediaApp
|
||||
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.withContext
|
||||
|
||||
@@ -60,24 +72,48 @@ private class Tile(
|
||||
val glyph: ImageVector? = null,
|
||||
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
||||
val verified: Boolean = false,
|
||||
/** Known not to work; drawn with a red X instead of being left off the launcher. */
|
||||
val broken: Boolean = false,
|
||||
/** Starts and can be used, but rough enough not to call working; drawn with a yellow mark. */
|
||||
val partial: Boolean = false,
|
||||
val onLongClick: (() -> Unit)? = null,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
|
||||
private class Entry(
|
||||
internal class Entry(
|
||||
val label: String,
|
||||
val icon: ImageBitmap?,
|
||||
val template: TemplateApp? = 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. */
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
private object LauncherCache {
|
||||
@Volatile var entries: List<Entry>? = null
|
||||
internal object LauncherCache {
|
||||
/** 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. */
|
||||
@Synchronized
|
||||
@@ -87,39 +123,149 @@ private object LauncherCache {
|
||||
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
|
||||
}.getOrNull()
|
||||
|
||||
val nav = CarAppCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), template = it) }
|
||||
val media = MediaCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), media = it) }
|
||||
return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it }
|
||||
val navApps = CarAppCatalog.installed(context)
|
||||
val mediaApps = MediaCatalog.installed(context)
|
||||
// Some apps (Spotify among them) ship both a Car App Library template and a legacy
|
||||
// MediaBrowserService, which otherwise land as two identically-labeled tiles with no
|
||||
// way to tell them apart. They're genuinely different screens - browsing versus Now
|
||||
// Playing - so only the label needs disambiguating, not the tile itself.
|
||||
val mediaPackages = mediaApps.map { it.component.packageName }.toSet()
|
||||
|
||||
val 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, EXIT }
|
||||
|
||||
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||
entry.builtIn?.let {
|
||||
return when (it) {
|
||||
BuiltInApps.Phone -> AppGroup.PHONE
|
||||
BuiltInApps.Web -> AppGroup.WEB
|
||||
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||
BuiltInApps.Exit -> AppGroup.EXIT
|
||||
else -> AppGroup.SETTINGS
|
||||
}
|
||||
}
|
||||
if (entry.media != null) return AppGroup.MUSIC
|
||||
if (entry.messaging != null) return AppGroup.MESSAGING
|
||||
val app = entry.template ?: return AppGroup.OTHER
|
||||
// A media app's "· Browse" tile sits next to its player.
|
||||
if (app.component.packageName in mediaPackages) return AppGroup.MUSIC
|
||||
return when (app.kind) {
|
||||
"calling" -> AppGroup.PHONE
|
||||
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
|
||||
"messaging" -> AppGroup.MESSAGING
|
||||
"settings" -> AppGroup.SETTINGS
|
||||
else -> AppGroup.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
|
||||
* group with Settings last of all, then with the driver's own moves from [custom] laid over it.
|
||||
* Moved apps only trade places among themselves, so an app installed later still lands in its group.
|
||||
*/
|
||||
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> {
|
||||
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
|
||||
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
|
||||
val grouped = shown.sortedWith(
|
||||
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
|
||||
)
|
||||
if (custom.isEmpty()) return grouped
|
||||
val rank = custom.withIndex().associate { (i, id) -> id to i }
|
||||
fun Entry.rank() = componentId?.let { rank[it] }
|
||||
val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
|
||||
return grouped.map { if (it.rank() != null) moved.next() else it }
|
||||
}
|
||||
|
||||
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
||||
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. */
|
||||
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
||||
|
||||
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
||||
/**
|
||||
* Connects or opens [entry] - what tapping its tile does, shared with the nav bar's pinned
|
||||
* shortcuts and its open-app icon so there is exactly one place that knows how to launch either
|
||||
* kind of app.
|
||||
*/
|
||||
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
|
||||
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.messaging?.let { navigator.messages(it.packageName, it.label) }
|
||||
}
|
||||
|
||||
/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */
|
||||
@Composable
|
||||
fun CarLauncher() {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalCarNavigator.current
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
|
||||
// Painted from the cache at once; scanned only if [Prefetch] has not got there first.
|
||||
val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
|
||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
val all = rememberAllEntries()
|
||||
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.isPartial(pkg) && (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 tiles = listOf(
|
||||
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
||||
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
||||
) + installed.map { entry ->
|
||||
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
||||
Tile(entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg)) {
|
||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||
}
|
||||
} + listOf(
|
||||
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
|
||||
)
|
||||
val appOrder by CarSettings.appOrder.collectAsState()
|
||||
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||
@@ -129,27 +275,49 @@ fun CarLauncher() {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(tiles) { AppTile(it) }
|
||||
if (tiles.any { it.verified }) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { VerifiedLegend() }
|
||||
if (tiles.any { it.verified || it.broken || it.partial }) {
|
||||
// Folded away behind one button: it's reference, read once, not something every
|
||||
// visit to the launcher needs spelled out under the grid.
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
GsButton(onClick = { showBadgeKey = !showBadgeKey }, tone = GsTone.Neutral) {
|
||||
Icon(Icons.Filled.Info, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (showBadgeKey) "Hide badge key" else "What the badges mean")
|
||||
}
|
||||
if (showBadgeKey) {
|
||||
BadgeLegend(
|
||||
verified = tiles.any { it.verified },
|
||||
partial = tiles.any { it.partial },
|
||||
broken = tiles.any { it.broken },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Says what 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
|
||||
private fun VerifiedLegend() {
|
||||
Row(
|
||||
private fun BadgeLegend(verified: Boolean, partial: Boolean, broken: Boolean) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
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))
|
||||
Text(
|
||||
"Tested and working with Gearslip",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,14 +332,52 @@ private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier
|
||||
}
|
||||
}
|
||||
|
||||
/** A red disc with a white X: marks a tile known not to work with Gearslip yet. */
|
||||
@Composable
|
||||
private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
}
|
||||
}
|
||||
|
||||
/** A yellow disc with a warning mark: neither the check nor the X, since this app runs but isn't clean about it. */
|
||||
@Composable
|
||||
private fun PartialBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.size(size).background(Color(0xFFF9A825), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Warning, contentDescription = "Works, but not cleanly, with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.6f))
|
||||
}
|
||||
}
|
||||
|
||||
/** Trial: where a tile says whether its app works. See [AppTileBody]. */
|
||||
private enum class BadgeStyle { ON_ICON, OUTLINE }
|
||||
private val BADGE_STYLE = BadgeStyle.ON_ICON
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AppTile(tile: Tile) {
|
||||
Surface(
|
||||
val status = when {
|
||||
tile.verified -> Color(0xFF43A047)
|
||||
tile.partial -> Color(0xFFF9A825)
|
||||
tile.broken -> Color(0xFFD32F2F)
|
||||
else -> null
|
||||
}
|
||||
val shape = MaterialTheme.shapes.extraLarge
|
||||
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
||||
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
|
||||
GsIconBox(
|
||||
onClick = tile.onClick,
|
||||
modifier = Modifier.height(120.dp),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
onLongClick = tile.onLongClick,
|
||||
modifier = Modifier.height(120.dp).then(
|
||||
if (BADGE_STYLE == BadgeStyle.OUTLINE && status != null) Modifier.border(2.5.dp, status, shape) else Modifier,
|
||||
),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
shape = shape,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -179,23 +385,44 @@ private fun AppTile(tile: Tile) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when {
|
||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
Box {
|
||||
TileIcon(tile)
|
||||
if (BADGE_STYLE == BadgeStyle.ON_ICON) {
|
||||
// Pinned to the icon's lower corner, ringed in the tile's colour so it
|
||||
// reads as sitting on top of the icon.
|
||||
val ring = Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp)
|
||||
.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape)
|
||||
when {
|
||||
tile.verified -> VerifiedBadge(22.dp, ring)
|
||||
tile.broken -> BrokenBadge(22.dp, ring)
|
||||
tile.partial -> PartialBadge(22.dp, ring)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
tile.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (tile.verified) {
|
||||
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||
TileLabel(tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileIcon(tile: Tile) {
|
||||
when {
|
||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
tile.label,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@@ -16,66 +17,136 @@ sealed interface CarScreen {
|
||||
/** Opened from the weather chip or the dashboard: now, the next hours and the week. */
|
||||
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. */
|
||||
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. */
|
||||
data class Notifications(val replyTo: String? = null) : 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. */
|
||||
data class Crumb(val screen: CarScreen, val label: String)
|
||||
|
||||
/**
|
||||
* Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
|
||||
* the on-screen one; an open app can claim it first (a browser going back a page) through
|
||||
* [backInterceptor].
|
||||
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
|
||||
* every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
|
||||
* or a file manager's breadcrumb bar) rather than replacing the last one outright - that's what
|
||||
* lets the breadcrumb bar show "Home > Apps > Settings" and jump straight back to any of them,
|
||||
* and lets [back] undo exactly one step instead of always dumping you back at Home. An open app
|
||||
* can claim a back step first (a browser going back a page) through [backInterceptor].
|
||||
*/
|
||||
class CarNavigator(startId: String) {
|
||||
|
||||
var current: CarScreen by mutableStateOf(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
private val stack = mutableStateListOf(
|
||||
Crumb(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
"Home",
|
||||
),
|
||||
)
|
||||
private set
|
||||
|
||||
/** The whole trail, root first. Size 1 means "at Home" - the breadcrumb bar hides then. */
|
||||
val trail: List<Crumb> get() = stack
|
||||
|
||||
val current: CarScreen get() = stack.last().screen
|
||||
|
||||
var backInterceptor: (() -> Boolean)? = null
|
||||
|
||||
fun home() { current = CarScreen.Home }
|
||||
|
||||
/**
|
||||
* Minimised, the home screen's player folds into the nav bar and the map takes the width.
|
||||
* Opening the media screen from the bar restores it, so leaving that screen lands on the
|
||||
* home screen with the player back in place.
|
||||
*/
|
||||
var playerMinimised by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Set by the player's Lyrics button; the media screen reads it once when it opens. */
|
||||
var lyricsRequested = false
|
||||
|
||||
fun minimisePlayer() { playerMinimised = true }
|
||||
|
||||
fun restorePlayer() { playerMinimised = false }
|
||||
|
||||
fun media(lyrics: Boolean = false) {
|
||||
lyricsRequested = lyrics
|
||||
playerMinimised = false
|
||||
current = CarScreen.Media
|
||||
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
|
||||
* where you already are shouldn't grow the trail. */
|
||||
private fun push(screen: CarScreen, label: String) {
|
||||
if (stack.last().screen == screen) return
|
||||
stack.add(Crumb(screen, label))
|
||||
}
|
||||
|
||||
fun apps() { current = CarScreen.Apps }
|
||||
/** The one way back to a clean slate: resets the whole trail to just Home. */
|
||||
fun home() {
|
||||
stack.clear()
|
||||
stack.add(Crumb(CarScreen.Home, "Home"))
|
||||
}
|
||||
|
||||
fun settings() { current = CarScreen.Settings }
|
||||
/**
|
||||
* What the column beside the map on Home is showing, if anything. The player lives in one
|
||||
* place at a time: with [SidePanel.CONTROLS] it moves into the column and the nav bar's pill
|
||||
* steps aside; with [SidePanel.LYRICS] the column is all lyrics and the pill comes back.
|
||||
* Every other screen has no room for the column, so there the pill stays in the nav bar.
|
||||
*/
|
||||
var sidePanel by mutableStateOf(SidePanel.NONE)
|
||||
private set
|
||||
|
||||
fun dashboard() { current = CarScreen.Dashboard }
|
||||
/**
|
||||
* 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 weather() { current = CarScreen.Weather }
|
||||
fun showSideLyrics() { sidePanel = SidePanel.LYRICS }
|
||||
|
||||
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
|
||||
fun closeSidePanel() { sidePanel = SidePanel.NONE }
|
||||
|
||||
fun open(id: String) { current = CarScreen.App(id) }
|
||||
/** 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 browse(label: String) { push(CarScreen.Browse, label) }
|
||||
|
||||
fun apps() { push(CarScreen.Apps, "Apps") }
|
||||
|
||||
fun settings() { push(CarScreen.Settings, "Settings") }
|
||||
|
||||
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
|
||||
|
||||
fun weather() { push(CarScreen.Weather, "Weather") }
|
||||
|
||||
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) }
|
||||
|
||||
/** Jumps straight to a step already on the trail, dropping everything after it - what
|
||||
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
|
||||
fun jumpTo(index: Int) {
|
||||
while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
|
||||
/** One step back, same as tapping the crumb before the current one. */
|
||||
fun back() {
|
||||
if (backInterceptor?.invoke() == true) return
|
||||
current = CarScreen.Home
|
||||
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 "GPS satellites",
|
||||
22 to "Toll card",
|
||||
)
|
||||
|
||||
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.Context
|
||||
import android.media.session.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
import app.seb3thehacker.gearslip.host.CarAppCatalog
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaApp
|
||||
import app.seb3thehacker.gearslip.media.MediaCatalog
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -29,9 +33,23 @@ object CarServices {
|
||||
val nav: CarAppConnection by lazy { CarAppConnection(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)
|
||||
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. */
|
||||
private var navStopped = false
|
||||
private var autoplayPending = false
|
||||
@@ -39,14 +57,30 @@ object CarServices {
|
||||
fun init(appContext: Context) {
|
||||
context = appContext.applicationContext
|
||||
app.seb3thehacker.gearslip.notify.CarMotion.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 -----------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
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() {
|
||||
@@ -54,6 +88,23 @@ object CarServices {
|
||||
nav.disconnect()
|
||||
}
|
||||
|
||||
/** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
|
||||
suspend fun reconnectNav(frame: CarEnvironment.Frame) {
|
||||
navStopped = false
|
||||
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 ---------------------------------------------------------------------------
|
||||
|
||||
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
||||
@@ -70,6 +121,14 @@ object CarServices {
|
||||
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. */
|
||||
fun autoplayIfDue() {
|
||||
if (!autoplayPending) return
|
||||
@@ -84,29 +143,65 @@ object CarServices {
|
||||
val ctx = context ?: return
|
||||
|
||||
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 app = last?.let { id ->
|
||||
withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) }
|
||||
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
|
||||
}
|
||||
// The one used last, or - first run, nothing on record yet - whichever comes first:
|
||||
// a driver who plugs in for the first time shouldn't have to go pick one by hand
|
||||
// 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 (_mediaApp.value == null) {
|
||||
val last = CarSettings.lastMedia.value
|
||||
val app = last?.let { id ->
|
||||
withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
|
||||
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
|
||||
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 app = last?.let { id -> catalog.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. */
|
||||
fun shutdown() {
|
||||
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
|
||||
app.seb3thehacker.gearslip.call.CarCalls.stop()
|
||||
runCatching { nav.disconnect() }
|
||||
runCatching { media.disconnect() }
|
||||
runCatching { browse.disconnect() }
|
||||
CarAssistant.cancel()
|
||||
CarAudio.release()
|
||||
_mediaApp.value = null
|
||||
navStopped = false
|
||||
|
||||
@@ -2,11 +2,18 @@ package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
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.StateFlow
|
||||
|
||||
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). */
|
||||
enum class AppTheme { PHONE, LIGHT, DARK }
|
||||
|
||||
@@ -23,7 +30,23 @@ object CarSettings {
|
||||
private const val KEY_LAST_NAV = "car_last_nav"
|
||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||
private const val KEY_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_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_MEDIA_SEARCH = "car_media_search_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
|
||||
* still be on the bar, not off the edge of a long list. */
|
||||
const val MAX_PINNED = 3
|
||||
|
||||
/** Id of the app to open on connect; [HOME] opens the launcher. */
|
||||
const val HOME = "home"
|
||||
@@ -37,8 +60,72 @@ object CarSettings {
|
||||
private val lastNavFlow = MutableStateFlow<String?>(null)
|
||||
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
private val playerTitleFlow = MutableStateFlow(false)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
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 appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||
private val voiceAssistantFlow = MutableStateFlow(false)
|
||||
private val carSensorsFlow = MutableStateFlow(false)
|
||||
private val mediaSearchFlow = 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. */
|
||||
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. */
|
||||
val lastNav: StateFlow<String?> = lastNavFlow
|
||||
@@ -47,6 +134,12 @@ object CarSettings {
|
||||
/** Whether the media app starts playing by itself when the car connects. */
|
||||
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 openOnConnect: StateFlow<String> = openFlow
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
@@ -59,6 +152,47 @@ object CarSettings {
|
||||
*/
|
||||
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
|
||||
|
||||
/**
|
||||
* Searching inside a media app is experimental and off by default: many apps claim to take
|
||||
* searches and then ignore them or play something else. The media screen's search button
|
||||
* shows only once this and [experimentalFeaturesEnabled] are both on.
|
||||
*/
|
||||
val mediaSearchEnabled: StateFlow<Boolean> = mediaSearchFlow
|
||||
|
||||
/**
|
||||
* Whether the car screen's own "drive safely" warning has already been shown and dismissed
|
||||
* 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) {
|
||||
app = context.applicationContext
|
||||
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
|
||||
@@ -68,11 +202,27 @@ object CarSettings {
|
||||
themeFlow.value = runCatching {
|
||||
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
|
||||
}.getOrDefault(AppTheme.PHONE)
|
||||
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
nightFlow.value = runCatching {
|
||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||
}.getOrDefault(NightMode.AUTO)
|
||||
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
||||
.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"
|
||||
mediaSearchFlow.value = AppSettings.getString(app, KEY_MEDIA_SEARCH, "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) {
|
||||
@@ -100,6 +250,16 @@ object CarSettings {
|
||||
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) {
|
||||
autoplayFlow.value = on
|
||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||
@@ -110,9 +270,47 @@ object CarSettings {
|
||||
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 setMediaSearchEnabled(on: Boolean) {
|
||||
mediaSearchFlow.value = on
|
||||
AppSettings.putString(app, KEY_MEDIA_SEARCH, on.toString())
|
||||
}
|
||||
|
||||
fun setNightMode(mode: NightMode) {
|
||||
nightFlow.value = mode
|
||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||
CarEnvironment.refresh()
|
||||
}
|
||||
|
||||
/** Long-pressed onto or off the nav bar. Silently does nothing past [MAX_PINNED] rather than
|
||||
* bumping the oldest pin - a driver who filled the bar on purpose shouldn't lose one by accident. */
|
||||
fun togglePin(component: String) {
|
||||
val current = pinnedFlow.value
|
||||
val next = when {
|
||||
component in current -> current - component
|
||||
current.size >= MAX_PINNED -> return
|
||||
else -> current + component
|
||||
}
|
||||
pinnedFlow.value = next
|
||||
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
|
||||
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -28,6 +32,9 @@ 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.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.ui.CertSummary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -43,9 +50,17 @@ fun CarSettingsScreen() {
|
||||
val scale by CarSettings.scale.collectAsState()
|
||||
val open by CarSettings.openOnConnect.collectAsState()
|
||||
val night by CarSettings.nightMode.collectAsState()
|
||||
val units by CarSettings.units.collectAsState()
|
||||
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.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 mediaSearch by CarSettings.mediaSearchEnabled.collectAsState()
|
||||
val display by CarEnvironment.display.collectAsState()
|
||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||
val context = LocalContext.current
|
||||
@@ -60,7 +75,7 @@ fun CarSettingsScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader("Settings")
|
||||
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
@@ -80,12 +95,30 @@ fun CarSettingsScreen() {
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
|
||||
ChoiceRow(
|
||||
"Theme",
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
|
||||
ChoiceRow(
|
||||
"Map day/night",
|
||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
|
||||
ChoiceRow(
|
||||
"Units",
|
||||
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
|
||||
ChoiceRow(
|
||||
"Player in the nav bar",
|
||||
listOf(false to "Skip buttons", true to "Song title"),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
|
||||
ChoiceRow(
|
||||
"Autoplay on connect",
|
||||
listOf(false to "Off", true to "On"),
|
||||
@@ -94,14 +127,13 @@ fun CarSettingsScreen() {
|
||||
|
||||
ChoiceRow(
|
||||
"Media audio",
|
||||
listOf(false to "Phone output", true to "Through Gearslip"),
|
||||
listOf(false to "Phone audio", true to "Car audio"),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
Text(
|
||||
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
|
||||
"first time a media app plays in the car, and some apps refuse to be captured at " +
|
||||
"all. Phone output needs no permission: the app plays as it normally would, for " +
|
||||
"example over Bluetooth to the car.",
|
||||
"Car audio sends the phone's sound to the car over USB. Android asks for permission " +
|
||||
"when the car connects, and a few apps block it. Phone audio plays as the phone " +
|
||||
"normally would, for example over Bluetooth to the car.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -110,6 +142,15 @@ fun CarSettingsScreen() {
|
||||
|
||||
ScreenFit(vehicle)
|
||||
|
||||
ChoiceRow(
|
||||
"Experimental features",
|
||||
listOf(false to "Hide", true to "Show"),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
if (experimentalFeatures) {
|
||||
ExperimentalFeaturesList(voiceAssistant, carSensors, mediaSearch)
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
@@ -117,9 +158,20 @@ fun CarSettingsScreen() {
|
||||
Info("Video", display.ifEmpty { "-" })
|
||||
Info("Certificate", cert?.headline ?: "Checking…")
|
||||
}
|
||||
val navigator = LocalCarNavigator.current
|
||||
Button(onClick = { navigator.open("calibrate") }) { Text("Display test") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,24 +182,96 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
options.forEach { (value, label) ->
|
||||
val on = value == selected
|
||||
Surface(
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
GsIconBox(
|
||||
onClick = { onSelect(value) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(64.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
|
||||
else GsColors(scheme.surfaceContainerHigh, scheme.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, mediaSearch: 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) }
|
||||
|
||||
ExperimentalFeatureRow(
|
||||
title = "Media search",
|
||||
description = "Adds a search button to the media screen. Some apps ignore the search " +
|
||||
"or play something else.",
|
||||
checked = mediaSearch,
|
||||
) { CarSettings.setMediaSearchEnabled(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@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. */
|
||||
@Composable
|
||||
private fun WebPageSetting() {
|
||||
@@ -185,6 +309,7 @@ private fun WebPageSetting() {
|
||||
AppSettings.setStartupUrl(context, it)
|
||||
},
|
||||
onSubmit = { editing = false },
|
||||
onDismiss = { editing = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -223,7 +348,7 @@ private fun ScreenFit(vehicle: String) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
GsButton(
|
||||
onClick = {
|
||||
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
|
||||
saved = if (ok) "Saved to $vehicle" else "Could not save"
|
||||
@@ -254,14 +379,13 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
|
||||
Surface(
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(64.dp),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -12,19 +13,29 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -38,16 +49,31 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -56,7 +82,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -71,6 +100,7 @@ fun CarUi() {
|
||||
val scale by CarSettings.scale.collectAsState()
|
||||
val phoneDark by CarEnvironment.phoneDark.collectAsState()
|
||||
val appTheme by CarSettings.appTheme.collectAsState()
|
||||
val uiThemeId by CarSettings.uiTheme.collectAsState()
|
||||
val mediaApp by CarServices.mediaApp.collectAsState()
|
||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
@@ -92,14 +122,47 @@ fun CarUi() {
|
||||
}
|
||||
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
|
||||
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
|
||||
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
|
||||
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
||||
// somewhere to play it.
|
||||
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
||||
// The map follows the light outside, whatever the app's own theme is set to.
|
||||
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(
|
||||
LocalDensity provides Density(base.density * scale, base.fontScale),
|
||||
LocalCarNavigator provides navigator,
|
||||
LocalDarkOutside provides darkOutside,
|
||||
LocalMapControlsWake provides controlsWake,
|
||||
LocalGsTheme provides GsThemes.byId(uiThemeId),
|
||||
) {
|
||||
MaterialTheme(colorScheme = colors) {
|
||||
Surface(
|
||||
@@ -123,29 +186,340 @@ fun CarUi() {
|
||||
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
||||
)
|
||||
}
|
||||
// The calibration screen is the raw frame: no insets, no nav bar.
|
||||
val screen = navigator.current
|
||||
if (screen == CarScreen.App("calibrate")) {
|
||||
CarApps.find("calibrate")?.content?.invoke()
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize().padding(pad)) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> CarHome()
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
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.
|
||||
// BreadcrumbBar(navigator)
|
||||
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) {
|
||||
CarScreen.Home -> Unit
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
||||
CarScreen.Browse -> BrowseAppScreen(navigator)
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
CarScreen.VehicleData -> VehicleDataScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.Messages -> MessagesScreen(screen.packageName)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
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))
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
// The card and its button share one width, capped so neither sprawls across a wide screen.
|
||||
Column(
|
||||
Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
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
|
||||
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
||||
* back at all, since a car touchscreen has no hardware button for it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||
val trail = navigator.trail
|
||||
if (trail.size <= 1) return
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back, size = 32.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
trail.forEachIndexed { index, crumb ->
|
||||
val isLast = index == trail.lastIndex
|
||||
Text(
|
||||
crumb.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (isLast) Modifier else Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { navigator.jumpTo(index) }
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
if (!isLast) {
|
||||
Text(
|
||||
"›",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
||||
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
||||
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
||||
* more room.
|
||||
*/
|
||||
@Composable
|
||||
private fun CarNavBar(navigator: CarNavigator) {
|
||||
val battery by CarEnvironment.battery.collectAsState()
|
||||
val now by rememberNow()
|
||||
val weather by Weather.state.collectAsState()
|
||||
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||
|
||||
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||
val entries = rememberAllEntries()
|
||||
val pinnedEntries = remember(entries, pinnedIds) {
|
||||
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
}
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
val running = rememberRunningApps()
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// Home and Apps are two different places now, not a toggle on one button - the
|
||||
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
||||
// these two from three screens deep in a media app shouldn't mean reading it
|
||||
// first.
|
||||
NavHomeButton(navigator, entries, running)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val voiceEnabled by CarSettings.voiceAssistantEnabled.collectAsState()
|
||||
if (experimentalFeatures && voiceEnabled) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavAssistantIcon()
|
||||
}
|
||||
|
||||
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||
// The map app lives on the Home button, so it never gets a second icon here.
|
||||
val runningEntries = running.keys
|
||||
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
||||
.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
||||
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
||||
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
||||
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||
|
||||
// Next turn first, off the map only; the player shares the space when it fits,
|
||||
// and gives way to the turn when it doesn't.
|
||||
val turn = rememberNextTurn().takeIf { navigator.current != CarScreen.Home }
|
||||
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
turn?.let { NavTurnPill(it) { navigator.home() } }
|
||||
if (turn == null || roomForBoth) {
|
||||
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weather no longer has its own chip - the temperature alone (no icon, no room
|
||||
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
|
||||
// Home button above.
|
||||
// The clock is also the notifications button: the dashboard lists them under the
|
||||
// calendar and weather, and a dot here marks anything unread.
|
||||
val unread by CarNotifications.unread.collectAsState()
|
||||
NavChip(
|
||||
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
||||
{ navigator.dashboard() },
|
||||
) {
|
||||
if (unread > 0) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-2).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
if (battery.percent >= 0) {
|
||||
Text(
|
||||
"${battery.percent}%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (battery.charging) {
|
||||
ChargingGlyph(Modifier.size(13.dp))
|
||||
}
|
||||
}
|
||||
weatherData?.let { w ->
|
||||
Text(
|
||||
" · ${w.temp}°",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
CarNavBar(navigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,94 +528,140 @@ fun CarUi() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Home and Apps on the left; now-playing pill in the middle; clock (tap for calendar and weather), phone battery and the
|
||||
* darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it
|
||||
* keeps the bar short enough to leave the app underneath more room.
|
||||
* 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 CarNavBar(navigator: CarNavigator) {
|
||||
val battery by CarEnvironment.battery.collectAsState()
|
||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val now by rememberNow()
|
||||
|
||||
// The home screen shows what's playing itself, unless it was minimised; everywhere else it lives in this bar.
|
||||
val showNowPlaying = navigator.current != CarScreen.Home || navigator.playerMinimised
|
||||
val weather by Weather.state.collectAsState()
|
||||
|
||||
// One row, so nothing can sit on top of anything else: the player takes whatever width the
|
||||
// fixed items leave and shortens its title to fit.
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
|
||||
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
||||
if (showNowPlaying) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(weather as? WeatherState.Ready)?.data?.let { w ->
|
||||
NavChip(navigator.current == CarScreen.Weather, { navigator.weather() }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
WeatherIcon(w.code, w.isDay, size = 28.dp)
|
||||
Text("${w.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
|
||||
val light = if (darkOutside) "Night" else "Day"
|
||||
Text(
|
||||
listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
NavItem(
|
||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
|
||||
) { navigator.notifications() }
|
||||
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
||||
@Composable
|
||||
private fun NavDivider() {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.width(1.dp)
|
||||
.height(28.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
||||
* already open; a long press opens its menu (pin, unpin, close).
|
||||
*/
|
||||
@Composable
|
||||
private fun NavEntryIcon(
|
||||
entry: Entry,
|
||||
running: Map<String, RunningSlot>,
|
||||
navigator: CarNavigator,
|
||||
frame: CarEnvironment.Frame,
|
||||
) {
|
||||
val id = entry.componentId ?: return
|
||||
val slot = running[id]
|
||||
Box(Modifier.padding(horizontal = 3.dp)) {
|
||||
NavAppIcon(
|
||||
entry,
|
||||
open = slot != null,
|
||||
showing = slot != null && slot.isShowing(navigator.current),
|
||||
onLongClick = { navigator.showAppMenu(id) },
|
||||
) { openApp(entry, slot, navigator, frame) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||
@Composable
|
||||
private fun NavAppIcon(
|
||||
entry: Entry,
|
||||
open: Boolean,
|
||||
showing: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
GsIconBox(
|
||||
onClick,
|
||||
Modifier.size(44.dp),
|
||||
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
||||
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
||||
latched = showing,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
entry.builtIn?.let { Icon(it.glyph, entry.label, Modifier.size(26.dp)) }
|
||||
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||
if (open) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 2.dp)
|
||||
.size(width = 12.dp, height = 3.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(scheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||
@Composable
|
||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
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() }
|
||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
||||
@Composable
|
||||
private fun navColors(selected: Boolean): GsColors {
|
||||
val s = MaterialTheme.colorScheme
|
||||
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
||||
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
||||
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
||||
* two buttons beside it.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavAssistantIcon() {
|
||||
val state by CarAssistant.state.collectAsState()
|
||||
val active = state.phase != CarAssistant.Phase.IDLE
|
||||
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
||||
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
||||
private val NAV_ITEM_PADDING = 8.dp
|
||||
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
|
||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,57 +674,208 @@ internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis(
|
||||
}
|
||||
}
|
||||
|
||||
/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
|
||||
/** How long the pill shows a new song's title before settling to just the controls. */
|
||||
private const val TITLE_SHOWN_MS = 3_000L
|
||||
|
||||
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
||||
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||
|
||||
/** The next manoeuvre, as the running nav app last described it. */
|
||||
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String)
|
||||
|
||||
/**
|
||||
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||
* while it's running, whether or not the map is the screen being shown.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberNextTurn(): NextTurn? {
|
||||
val status by CarServices.nav.status.collectAsState()
|
||||
val template by CarServices.nav.template.collectAsState()
|
||||
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||
CarSettings.units.collectAsState().value
|
||||
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
||||
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
||||
if (routing.isLoading) return null
|
||||
val step = routing.currentStep ?: return null
|
||||
return NextTurn(
|
||||
icon = step.maneuver?.icon,
|
||||
distance = routing.currentDistance.display(),
|
||||
road = step.road.text().ifEmpty { step.cue.text() },
|
||||
)
|
||||
}
|
||||
|
||||
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||
@Composable
|
||||
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
||||
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
||||
GsIconBox(
|
||||
onClick,
|
||||
Modifier.widthIn(max = 340.dp).height(44.dp),
|
||||
colors = gsColors(GsTone.Primary),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(NAV_ITEM_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CarGlyph(turn.icon, Modifier.size(28.dp))
|
||||
if (turn.distance.isNotEmpty()) {
|
||||
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
if (turn.road.isNotEmpty()) {
|
||||
Text(
|
||||
turn.road,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
||||
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||
.ifEmpty { "Nothing playing" }
|
||||
|
||||
/**
|
||||
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
||||
* play/pause and skip work right here. The dock button moves the player into the column beside
|
||||
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
||||
* column is showing lyrics instead, when the pill stays and its dock button swaps them back.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsState()
|
||||
val now by media.now.collectAsState()
|
||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
||||
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
||||
// The media screen is the full player already; the pill would only repeat its controls.
|
||||
if (navigator.current == CarScreen.Media) return
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(50),
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.clickable { navigator.media() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(Modifier.size(40.dp).clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(44.dp)) {
|
||||
Icon(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
|
||||
val position by produceState(now.currentPosition(), now) {
|
||||
while (true) {
|
||||
value = now.currentPosition()
|
||||
if (!now.playing) break
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
||||
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
||||
val keepTitle by CarSettings.playerTitle.collectAsState()
|
||||
var announcing by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(now.title, now.artist) {
|
||||
announcing = true
|
||||
delay(TITLE_SHOWN_MS)
|
||||
announcing = false
|
||||
}
|
||||
val showTitle = keepTitle || announcing
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.animateContentSize()
|
||||
// With the title up, all the room the bar has, so as much of it shows as can.
|
||||
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
||||
.height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
||||
contentColor = onPill,
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Art (and the title while it shows) opens the full media screen.
|
||||
Row(
|
||||
Modifier
|
||||
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { navigator.media() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
if (showTitle) {
|
||||
Text(
|
||||
nowPlayingLabel(now.title, now.artist),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Previous, "Previous", media::previous,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
GsIconButton(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
media::togglePlay,
|
||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Next, "Next", media::next,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
GsIconButton(
|
||||
MediaIcons.DockRight, "Move the player beside the map", navigator::togglePlayerDock,
|
||||
size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
if (now.durationMs > 0) {
|
||||
// matchParentSize, so the line follows the pill's width rather than widening it.
|
||||
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
||||
.height(2.dp)
|
||||
.background(onPill.copy(alpha = 0.7f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
|
||||
* core Material icon set this project ships (no extended pack, to keep the app small) has no
|
||||
* bolt, so this is drawn rather than a vector asset.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val bolt = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(w * 0.58f, 0f)
|
||||
lineTo(w * 0.12f, h * 0.58f)
|
||||
lineTo(w * 0.46f, h * 0.58f)
|
||||
lineTo(w * 0.42f, h)
|
||||
lineTo(w * 0.88f, h * 0.40f)
|
||||
lineTo(w * 0.54f, h * 0.40f)
|
||||
close()
|
||||
}
|
||||
drawPath(bolt, tint)
|
||||
}
|
||||
}
|
||||
@@ -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,11 +1,17 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import android.provider.Settings
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -24,15 +30,14 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -44,7 +49,9 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.PathFillType
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||
@@ -62,7 +69,11 @@ import app.seb3thehacker.gearslip.media.LyricsResult
|
||||
import app.seb3thehacker.gearslip.media.Lyrics
|
||||
import app.seb3thehacker.gearslip.media.MediaEntry
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||
import android.media.session.PlaybackState
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
||||
/**
|
||||
* A media app on the car screen: what it is playing and its controls on the left, the tree it
|
||||
@@ -74,62 +85,121 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsStateWithLifecycle()
|
||||
val now by media.now.collectAsStateWithLifecycle()
|
||||
val queue by media.queue.collectAsStateWithLifecycle()
|
||||
val browse by media.browse.collectAsStateWithLifecycle()
|
||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||
val navigator = LocalCarNavigator.current
|
||||
var tab by remember {
|
||||
mutableStateOf(if (navigator.lyricsRequested) Tab.LYRICS else Tab.BROWSE).also { navigator.lyricsRequested = false }
|
||||
}
|
||||
val search by media.search.collectAsStateWithLifecycle()
|
||||
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
val searchAllowed = CarSettings.experimentalFeaturesEnabled.collectAsState().value && CarSettings.mediaSearchEnabled.collectAsState().value
|
||||
|
||||
// The connection outlives this screen: the home screen's player is the same one.
|
||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp)) {
|
||||
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)
|
||||
val art = rememberArt(now)
|
||||
|
||||
// The art's colour behind everything, header included; the buttons keep their own.
|
||||
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) {
|
||||
if (phase != CarMedia.Phase.READY) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
|
||||
when (phase) {
|
||||
CarMedia.Phase.REJECTED -> when (rejection) {
|
||||
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
||||
"${app.label} keeps its library to itself.",
|
||||
"Gearslip can still control it once it has Notification access on the phone. " +
|
||||
"Allow it under Settings > Notifications > Notification access.",
|
||||
action = "Open on phone" to {
|
||||
val app = context.applicationContext
|
||||
runCatching {
|
||||
app.startActivity(
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
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(
|
||||
"${app.label} would not let Gearslip browse it.",
|
||||
"Some media apps only accept Google's own host.",
|
||||
)
|
||||
}
|
||||
else -> Notice("Connecting to ${app.label}…", null)
|
||||
}
|
||||
}
|
||||
return@Surface
|
||||
}
|
||||
|
||||
when (phase) {
|
||||
CarMedia.Phase.REJECTED -> when (rejection) {
|
||||
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
||||
"${app.label} keeps its library to itself.",
|
||||
"Gearslip can still control it once it has Notification access on the phone. " +
|
||||
"Allow it under Settings > Notifications > Notification access.",
|
||||
action = "Open on phone" to {
|
||||
val app = context.applicationContext
|
||||
runCatching {
|
||||
app.startActivity(
|
||||
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
CarMedia.Rejection.REFUSED -> Notice(
|
||||
"${app.label} would not let Gearslip browse it.",
|
||||
"Some media apps only accept Google's own host.",
|
||||
)
|
||||
if (searching) {
|
||||
SearchPanel(
|
||||
appLabel = app.label,
|
||||
media = media,
|
||||
canPickForYou = now.canSearch,
|
||||
onPlayed = {
|
||||
searching = false
|
||||
tab = Tab.NOW_PLAYING
|
||||
},
|
||||
onOpened = {
|
||||
searching = false
|
||||
tab = Tab.BROWSE
|
||||
},
|
||||
onPickForYou = { query ->
|
||||
media.search(query)
|
||||
searching = false
|
||||
tab = Tab.NOW_PLAYING
|
||||
},
|
||||
onClose = { searching = false },
|
||||
)
|
||||
return@Surface
|
||||
}
|
||||
|
||||
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
||||
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
||||
// top of the right column, over whatever the tab shows.
|
||||
Row(
|
||||
Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Column(Modifier.weight(0.8f).fillMaxHeight()) {
|
||||
AppIdentity(app, onExit)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (tab == Tab.NOW_PLAYING) {
|
||||
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
|
||||
} else {
|
||||
CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
|
||||
}
|
||||
AudioBadge(capture)
|
||||
}
|
||||
CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null)
|
||||
CarMedia.Phase.READY -> Row(
|
||||
Modifier.fillMaxSize().padding(top = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
NowPlayingPanel(now, media, Modifier.width(250.dp).fillMaxHeight())
|
||||
Column(Modifier.weight(1f).fillMaxHeight()) {
|
||||
Row {
|
||||
TextButton(onClick = { tab = Tab.BROWSE }) {
|
||||
Text("Browse", fontWeight = if (tab == Tab.BROWSE) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
TextButton(onClick = { tab = Tab.LYRICS }) {
|
||||
Text("Lyrics", fontWeight = if (tab == Tab.LYRICS) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
Column(Modifier.weight(1.2f).fillMaxHeight()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
|
||||
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
||||
if (searchAllowed && (now.canSearch || media.canSearchLibrary)) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
"Search ${app.label}",
|
||||
{ searching = true },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
size = 44.dp,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
)
|
||||
}
|
||||
}
|
||||
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (tab) {
|
||||
Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize())
|
||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
|
||||
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
|
||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
@@ -138,17 +208,196 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Tab { BROWSE, LYRICS }
|
||||
/**
|
||||
* 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 }
|
||||
|
||||
// --- search ----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Types a search for the app to play. The app picks the result and plays it, the way it would
|
||||
* for "play X" from a voice assistant, so there's no list to choose from.
|
||||
*/
|
||||
/**
|
||||
* Search inside the media app. It asks the app's library for matches half a second after the
|
||||
* driver stops typing and lists them, so the driver picks exactly what plays: a song plays, an
|
||||
* album or artist opens in Browse. The search key hides the keyboard to show more results; the
|
||||
* field brings it back. An app with no library search can still be told to play its own best
|
||||
* match, which is all the panel could do before.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchPanel(
|
||||
appLabel: String,
|
||||
media: CarMedia,
|
||||
canPickForYou: Boolean,
|
||||
onPlayed: () -> Unit,
|
||||
onOpened: () -> Unit,
|
||||
onPickForYou: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var typing by remember { mutableStateOf(true) }
|
||||
val results by media.results.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(query) {
|
||||
delay(SEARCH_PAUSE_MS)
|
||||
media.searchLibrary(query)
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { media.clearResults() } }
|
||||
|
||||
val trimmed = query.trim()
|
||||
val current = results?.takeIf { it.query == trimmed }
|
||||
val unsupported = current?.supported == false
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE)
|
||||
SearchField(
|
||||
query,
|
||||
"Search $appLabel",
|
||||
Modifier.weight(1f).clickable { typing = true },
|
||||
onClear = {
|
||||
query = ""
|
||||
typing = true
|
||||
},
|
||||
)
|
||||
}
|
||||
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
val entries = current?.entries
|
||||
when {
|
||||
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
|
||||
unsupported && canPickForYou ->
|
||||
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.")
|
||||
unsupported -> SearchNote("$appLabel can't search from the car.")
|
||||
entries == null -> SearchNote("Searching $appLabel…")
|
||||
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".")
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(entries) { entry ->
|
||||
EntryRow(entry, media) {
|
||||
if (entry.browsable) {
|
||||
media.select(entry)
|
||||
onOpened()
|
||||
} else {
|
||||
media.playAll(entry)
|
||||
onPlayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typing) {
|
||||
CarKeyboard(
|
||||
text = query,
|
||||
onTextChange = { query = it },
|
||||
onSubmit = {
|
||||
when {
|
||||
trimmed.isEmpty() -> Unit
|
||||
unsupported && canPickForYou -> onPickForYou(trimmed)
|
||||
else -> typing = false
|
||||
}
|
||||
},
|
||||
onDismiss = { if (trimmed.isEmpty()) onClose() else typing = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchNote(text: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long typing has to pause before the library is searched. */
|
||||
private const val SEARCH_PAUSE_MS = 1_000L
|
||||
|
||||
/** A line under the tabs while the app works on a search, and if it plays nothing for it. */
|
||||
@Composable
|
||||
private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onDismiss,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"."
|
||||
else "Searching $appLabel for \"${search.query}\"…",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
|
||||
@Composable
|
||||
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
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,
|
||||
latched = selected,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with
|
||||
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
||||
*/
|
||||
@Composable
|
||||
private 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) {
|
||||
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
|
||||
}
|
||||
val list = rememberLazyListState()
|
||||
@@ -178,7 +427,7 @@ private fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
|
||||
itemsIndexed(lines) { index, line ->
|
||||
Text(
|
||||
line.text.ifEmpty { " " },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = lineStyle,
|
||||
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
|
||||
color = when {
|
||||
!state.result.synced -> MaterialTheme.colorScheme.onSurface
|
||||
@@ -225,80 +474,97 @@ private fun Notice(title: String, detail: String?, action: Pair<String, () -> Un
|
||||
}
|
||||
action?.let { (label, run) ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
androidx.compose.material3.FilledTonalButton(onClick = run) { Text(label) }
|
||||
GsButton(onClick = run, tone = GsTone.Tonal) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- now playing -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The now-playing view on its own, for places with no tabs around it (an app's own
|
||||
* now-playing template): the cover on the left, [NowPlayingControls] on the right.
|
||||
*/
|
||||
@Composable
|
||||
private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) {
|
||||
value = now.art ?: MediaArt.load(context, now.artUri)
|
||||
}
|
||||
var position by remember { mutableLongStateOf(0L) }
|
||||
LaunchedEffect(now) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
kotlinx.coroutines.delay(500)
|
||||
}
|
||||
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
Row(
|
||||
modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
|
||||
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
/** The cover, as big a square as its space allows, centred in it. */
|
||||
@Composable
|
||||
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.size(170.dp),
|
||||
shadowElevation = 6.dp,
|
||||
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
|
||||
) {
|
||||
art?.let {
|
||||
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
|
||||
}
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||
}
|
||||
}
|
||||
|
||||
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.padding(vertical = 4.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,
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.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))
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever extra buttons the app publishes (shuffle, repeat, like), by name: their
|
||||
// icons are resources inside the app's own package.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
now.custom.take(3).forEach { action ->
|
||||
TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||
}
|
||||
/**
|
||||
* 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(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth())
|
||||
TransportRow(now, media, skip = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||
PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,15 +573,22 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
|
||||
@Composable
|
||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
||||
Column(modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
|
||||
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
||||
// row could say that the app's name in the top-left corner hasn't already - showing it
|
||||
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
||||
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (media.canGoUp) {
|
||||
GsIconButton(Icons.Filled.ArrowBack, "Up", media::up)
|
||||
}
|
||||
browse.trail.lastOrNull()?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
browse.trail.lastOrNull() ?: "Browse",
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
when {
|
||||
browse.loading -> Notice("Loading…", null)
|
||||
@@ -332,20 +605,75 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
|
||||
}
|
||||
}
|
||||
|
||||
// --- up next -----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
||||
* whole queue from the top, which would just repeat what the player beside it already shows. Apps
|
||||
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
||||
*/
|
||||
@Composable
|
||||
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
|
||||
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||
val upNext = remember(queue, now.activeQueueItemId) {
|
||||
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
||||
if (current >= 0) queue.drop(current + 1) else queue
|
||||
}
|
||||
Column(modifier) {
|
||||
if (upNext.isEmpty()) {
|
||||
Notice(
|
||||
"Nothing queued.",
|
||||
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
||||
"not every app tells Gearslip what comes next.",
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||
val icon by produceState(track.iconBitmap, track.iconUri) {
|
||||
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = { media.select(entry) },
|
||||
onClick = { media.playQueueItem(track) },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(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) }
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (track.subtitle.isNotEmpty()) {
|
||||
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(62.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
|
||||
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||
@@ -355,7 +683,7 @@ private fun EntryRow(entry: MediaEntry, media: CarMedia) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -369,8 +697,14 @@ internal object MediaIcons {
|
||||
/** The 3x3 app grid; not in material-icons-core. */
|
||||
val Apps = icon("Apps", "M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z")
|
||||
val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z")
|
||||
val Equalizer = icon("Equalizer", "M10,20h4L14,4h-4v16zM4,20h4v-8L4,12v8zM16,9v11h4L20,9h-4z")
|
||||
val Shuffle = icon("Shuffle", "M10.59,9.17L5.41,4 4,5.41l5.17,5.17 1.42,-1.41zM14.5,4l2.04,2.04L4,18.59 5.41,20 17.96,7.46 20,9.5L20,4h-5.5zM14.83,13.41l-1.41,1.41 3.13,3.13L14.5,20L20,20v-5.5l-2.04,2.04 -3.13,-3.13z")
|
||||
val Repeat = icon("Repeat", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4z")
|
||||
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
|
||||
/** A screen with a filled right-hand panel: "put the player beside the map". */
|
||||
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
|
||||
|
||||
private fun icon(name: String, path: String) = ImageVector.Builder(
|
||||
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
|
||||
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,299 @@
|
||||
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
|
||||
import app.seb3thehacker.gearslip.CarFocus
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.PathParser
|
||||
|
||||
/** What a built-in app's id carries in a pin, so it can't collide with an installed app's component. */
|
||||
internal const val BUILT_IN_PREFIX = "gearslip:"
|
||||
|
||||
/** Material's "directions car", drawn here because only the core icon set is bundled. */
|
||||
private val CarGlyph: ImageVector = ImageVector.Builder("Car", 24.dp, 24.dp, 24f, 24f).addPath(
|
||||
PathParser().parsePathString(
|
||||
"M18.92,6.01C18.72,5.42 18.16,5 17.5,5h-11c-0.66,0 -1.21,0.42 -1.42,1.01L3,12v8c0,0.55 0.45,1 1,1h1" +
|
||||
"c0.55,0 1,-0.45 1,-1v-1h12v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-8l-2.08,-5.99z" +
|
||||
"M6.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,13 6.5,13s1.5,0.67 1.5,1.5S7.33,16 6.5,16z" +
|
||||
"M17.5,16c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z" +
|
||||
"M5,11l1.5,-4.5h11L19,11L5,11z",
|
||||
).toNodes(),
|
||||
fill = SolidColor(Color.Black),
|
||||
).build()
|
||||
|
||||
/**
|
||||
* One of Gearslip's own apps: a screen, with nothing running behind it once you leave. [screen] is
|
||||
* null for an action like Exit, which has no screen and so never shows as open.
|
||||
*/
|
||||
internal class BuiltInApp(
|
||||
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() }
|
||||
|
||||
/** Hands the screen back to the car's own interface, like Android Auto's Exit. */
|
||||
val Exit = BuiltInApp("exit", "Exit", CarGlyph, screen = null) { CarFocus.exitToCar() }
|
||||
|
||||
val all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit)
|
||||
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
||||
}
|
||||
|
||||
/** 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 != null && it.screen == current }?.let { put(BUILT_IN_PREFIX + it.id, RunningSlot.SCREEN) }
|
||||
(current as? CarScreen.Messages)?.let { put(MESSAGING_PREFIX + it.packageName, RunningSlot.SCREEN) }
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.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
|
||||
@@ -23,10 +21,7 @@ import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -35,6 +30,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -52,34 +48,40 @@ import kotlinx.coroutines.delay
|
||||
import java.text.DateFormat
|
||||
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
|
||||
fun NotificationsScreen(replyTo: String?) {
|
||||
val history by CarNotifications.history.collectAsState()
|
||||
var replying by remember(replyTo) { mutableStateOf(replyTo) }
|
||||
|
||||
// Anything arriving while this is open is being looked at, so it never counts as unread.
|
||||
LaunchedEffect(history) { CarNotifications.markRead() }
|
||||
|
||||
val target = history.firstOrNull { it.key == replying && it.reply != null }
|
||||
val navigator = LocalCarNavigator.current
|
||||
val target = history.firstOrNull { it.key == replyTo && it.reply != null }
|
||||
if (target != null) {
|
||||
ReplyPane(target, onDone = { replying = null })
|
||||
ReplyPane(target, onDone = navigator::back)
|
||||
} else {
|
||||
NotificationList(history, onReply = { replying = it.key })
|
||||
CarDashboardScreen()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
private fun NotificationList(history: List<CarNotification>, onReply: (CarNotification) -> Unit) {
|
||||
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() }
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"Notifications",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
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()) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Nothing yet. New notifications from the phone will show up here.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Nothing yet. New notifications from the phone will show up here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(history, key = { it.key }) { NotificationRow(it, onReply) }
|
||||
history.forEach { n ->
|
||||
key(n.key) { NotificationRow(n) { navigator.notifications(replyTo = it.key) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccessBanner(onOpen: () -> Unit) {
|
||||
internal fun AccessBanner(onOpen: () -> Unit) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
@@ -127,7 +128,7 @@ private fun AccessBanner(onOpen: () -> Unit) {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
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 {
|
||||
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
|
||||
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
|
||||
if (icon != null) {
|
||||
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,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") }
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -53,6 +53,8 @@ fun PhoneAppsScreen() {
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
||||
ScreenHeader("Screen sharing")
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (!TouchRelayService.enabled) {
|
||||
TouchRelayWarning()
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@@ -96,19 +98,18 @@ private fun TouchRelayWarning() {
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
|
||||
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
|
||||
Surface(
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.height(120.dp),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(8.dp),
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.Settings
|
||||
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.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** A phone contact with at least one number, as read off the device's contacts. */
|
||||
internal data class Contact(val name: String, val number: String)
|
||||
|
||||
private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS)
|
||||
|
||||
/** Also used by [AssistantCommands] for "call <name>". */
|
||||
internal fun hasCallPermissions(context: Context): Boolean =
|
||||
CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||
|
||||
internal fun loadContacts(context: Context): List<Contact> {
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList()
|
||||
val out = mutableListOf<Contact>()
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
)
|
||||
context.contentResolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection, null, null,
|
||||
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC",
|
||||
)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
|
||||
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
while (cursor.moveToNext()) {
|
||||
val name = cursor.getString(nameIndex) ?: continue
|
||||
val number = cursor.getString(numberIndex) ?: continue
|
||||
out += Contact(name, number)
|
||||
}
|
||||
}
|
||||
return out.distinctBy { it.number }
|
||||
}
|
||||
|
||||
internal fun placeCall(context: Context, number: String) {
|
||||
if (number.isBlank()) return
|
||||
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return
|
||||
runCatching {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_CALL, Uri.parse("tel:${Uri.encode(number)}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAB_CONTACTS = 0
|
||||
private const val TAB_KEYPAD = 1
|
||||
|
||||
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
|
||||
@Composable
|
||||
fun PhoneDialerScreen() {
|
||||
val context = LocalContext.current
|
||||
val hasPermissions = hasCallPermissions(context)
|
||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
||||
|
||||
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.
|
||||
ScreenHeader("Phone") {
|
||||
if (hasPermissions) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
||||
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPermissions) {
|
||||
MissingPermission(context)
|
||||
return@Column
|
||||
}
|
||||
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingPermission(context: Context) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
|
||||
"from the app's settings.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(tone = GsTone.Tonal, onClick = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
}) { Text("Open app settings") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
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,
|
||||
latched = selected,
|
||||
) {
|
||||
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
|
||||
* columns in all. Every row and both side buttons take an equal weighted share of whatever height
|
||||
* is available, so this fits the car's short landscape frame without scrolling instead of
|
||||
* overflowing off the bottom the way fixed-height rows did.
|
||||
*/
|
||||
@Composable
|
||||
private fun Keypad(context: Context) {
|
||||
var number by remember { mutableStateOf("") }
|
||||
val rows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("*", "0", "#"),
|
||||
)
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
number.ifEmpty { "Enter a number" },
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(vertical = 6.dp),
|
||||
)
|
||||
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Column(Modifier.weight(3f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
row.forEach { digit ->
|
||||
KeypadButton(digit, Modifier.weight(1f).fillMaxHeight()) { number += digit }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
GsButton(
|
||||
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
|
||||
tone = GsTone.Tonal,
|
||||
enabled = number.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) { Text("⌫", style = MaterialTheme.typography.titleLarge) }
|
||||
GsIconBox(
|
||||
onClick = { placeCall(context, number) },
|
||||
colors = gsColors(GsTone.Primary),
|
||||
enabled = number.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
) { Icon(Icons.Filled.Call, "Call") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) {
|
||||
GsIconBox(
|
||||
onClick,
|
||||
modifier,
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Text(digit, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactsList(context: Context) {
|
||||
val contacts by produceState(emptyList<Contact>(), context) {
|
||||
value = withContext(Dispatchers.IO) { loadContacts(context) }
|
||||
}
|
||||
var query by remember { mutableStateOf("") }
|
||||
var searching by remember { mutableStateOf(false) }
|
||||
var pendingCall by remember { mutableStateOf<Contact?>(null) }
|
||||
|
||||
val filtered = remember(contacts, query) {
|
||||
if (query.isBlank()) contacts
|
||||
else contacts.filter { it.name.contains(query, ignoreCase = true) || it.number.contains(query) }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (contacts.isNotEmpty()) {
|
||||
ContactSearchField(query, searching, onToggle = { searching = !searching })
|
||||
}
|
||||
// The list (and its empty states) sit between the search field and the keyboard, never
|
||||
// under either - the keyboard is docked at the bottom instead of jammed in right below
|
||||
// the field, so results stay visible while typing rather than getting squeezed out.
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when {
|
||||
contacts.isEmpty() -> Text(
|
||||
"No contacts with a phone number found.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
filtered.isEmpty() -> Text(
|
||||
"No contacts match \"$query\".",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
items(filtered) { contact ->
|
||||
ContactRow(contact) { pendingCall = contact }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (searching) {
|
||||
CarKeyboard(
|
||||
query,
|
||||
onTextChange = { query = it },
|
||||
onSubmit = { searching = false },
|
||||
onDismiss = { searching = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pendingCall?.let { contact ->
|
||||
ConfirmCallOverlay(
|
||||
contact = contact,
|
||||
onConfirm = { placeCall(context, contact.number); pendingCall = null },
|
||||
onDismiss = { pendingCall = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain Compose overlay, not [androidx.compose.material3.AlertDialog] - that opens a real
|
||||
* Android Dialog/window, and this UI lives in a Presentation on a virtual display where a second
|
||||
* window of the wrong type crashes outright ("Window type mismatch"). Every other popup in the
|
||||
* car UI is drawn in-composition for the same reason; this just follows suit.
|
||||
*/
|
||||
@Composable
|
||||
private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
onClick = {},
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(0.85f).padding(16.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Cancel", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
GsButton(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text("Call", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onToggle,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
query.ifEmpty { "Search contacts" },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(40.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
contact.name.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* The text field the car keyboard is typing into, shared with the phone so a passenger can type
|
||||
* on the phone's own keyboard instead. Whichever [CarKeyboard] is on screen registers here; the
|
||||
* phone app shows the same text while one is open, and its edits go back through the car field's
|
||||
* own callbacks, so the car screen can't tell which keyboard typed.
|
||||
*/
|
||||
object PhoneTyping {
|
||||
|
||||
class Field internal constructor(
|
||||
val id: Int,
|
||||
val text: String,
|
||||
internal val onTextChange: (String) -> Unit,
|
||||
internal val onSubmit: () -> Unit,
|
||||
internal val onDismiss: (() -> Unit)?,
|
||||
)
|
||||
|
||||
private val fieldFlow = MutableStateFlow<Field?>(null)
|
||||
|
||||
/** The car's open field, or null when no car keyboard is showing. */
|
||||
val field: StateFlow<Field?> = fieldFlow.asStateFlow()
|
||||
|
||||
private var nextId = 0
|
||||
|
||||
internal fun open(text: String, onTextChange: (String) -> Unit, onSubmit: () -> Unit, onDismiss: (() -> Unit)?): Int {
|
||||
val id = ++nextId
|
||||
fieldFlow.value = Field(id, text, onTextChange, onSubmit, onDismiss)
|
||||
return id
|
||||
}
|
||||
|
||||
/** The car field's text changed, from either keyboard. */
|
||||
internal fun carText(id: Int, text: String) {
|
||||
val f = fieldFlow.value ?: return
|
||||
if (f.id == id && f.text != text) fieldFlow.value = Field(id, text, f.onTextChange, f.onSubmit, f.onDismiss)
|
||||
}
|
||||
|
||||
internal fun close(id: Int) {
|
||||
if (fieldFlow.value?.id == id) fieldFlow.value = null
|
||||
}
|
||||
|
||||
/** From the phone: typed text goes to the car field as if typed there. */
|
||||
fun type(text: String) {
|
||||
fieldFlow.value?.onTextChange?.invoke(text)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
fieldFlow.value?.onSubmit?.invoke()
|
||||
}
|
||||
|
||||
/** Closes the car keyboard where it can be closed; elsewhere only the phone's field goes. */
|
||||
fun dismiss() {
|
||||
val f = fieldFlow.value ?: return
|
||||
f.onDismiss?.invoke() ?: close(f.id)
|
||||
}
|
||||
}
|
||||
@@ -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,13 +1,15 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.CertProvider
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Loads what the car screens show before anyone opens them: the car and media apps with their
|
||||
* icons, the phone's launcher apps, the weather and the calendar. Each screen paints from these
|
||||
* icons, the phone's launcher apps, the weather and the calendar. Also unlocks the certificate,
|
||||
* so a head unit's first message is answered at once. Each screen paints from these
|
||||
* results at once and refreshes behind them, so nothing waits on the package manager, the
|
||||
* network or the calendar provider.
|
||||
*
|
||||
@@ -28,6 +30,7 @@ object Prefetch {
|
||||
val app = context.applicationContext
|
||||
worker.execute {
|
||||
val start = System.currentTimeMillis()
|
||||
step("certificate") { CertProvider.warm(app) }
|
||||
step("launcher apps") { warmLauncherApps(app) }
|
||||
step("phone apps") { PhoneApps.warm(app) }
|
||||
step("weather and calendar") { warmDashboard(app) }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
|
||||
/**
|
||||
* The top of every screen reached from Home: a back button in the current theme, the title, and
|
||||
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
|
||||
* so this is the way out of a screen.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
Row(
|
||||
modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
trailing()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.graphics.Rect
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -13,28 +16,42 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
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.getValue
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.model.Template
|
||||
import androidx.car.app.model.PlaceListMapTemplate
|
||||
import androidx.car.app.navigation.model.Lane
|
||||
import androidx.car.app.navigation.model.LaneDirection
|
||||
import androidx.car.app.navigation.model.MapTemplate
|
||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import androidx.car.app.navigation.model.Step
|
||||
import androidx.car.app.navigation.model.TravelEstimate
|
||||
import app.seb3thehacker.gearslip.host.clockTime
|
||||
import app.seb3thehacker.gearslip.host.color
|
||||
import app.seb3thehacker.gearslip.host.image
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.remainingTime
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
@@ -96,7 +113,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||
)
|
||||
if (!pan) template.destinationTravelEstimate?.let {
|
||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
|
||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,7 +123,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
if (info.isLoading) {
|
||||
ChromeSurface(modifier) {
|
||||
Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
|
||||
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -120,89 +137,168 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
val distance = info.currentDistance.display()
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(distance, style = ChromeType.headline)
|
||||
}
|
||||
Text(
|
||||
step.cue.text(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
val road = step.road.text()
|
||||
if (road.isNotEmpty()) {
|
||||
Text(
|
||||
road,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Neither is sent by every app - most say only "turn left" and trust the driver to
|
||||
// find the lane themselves - so both are drawn only when there is something to draw.
|
||||
info.junctionImage?.let { junction ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
JunctionImage(junction)
|
||||
}
|
||||
LaneGuidance(step, Modifier.padding(top = 10.dp))
|
||||
info.nextStep?.let { next ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
"then",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
next.cue.text(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Time and distance left, along the bottom where it stays out of the map's way. */
|
||||
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
|
||||
@Composable
|
||||
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
||||
val bitmap = icon.image(LocalContext.current) ?: return
|
||||
Image(
|
||||
bitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = modifier.fillMaxWidth().heightIn(max = 120.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
|
||||
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
|
||||
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
|
||||
* everything else uses, never dimmed by transparency.
|
||||
*/
|
||||
@Composable
|
||||
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
|
||||
step.lanesImage?.let {
|
||||
JunctionImage(it, modifier)
|
||||
return
|
||||
}
|
||||
val lanes = step.lanes.orEmpty()
|
||||
if (lanes.isEmpty()) return
|
||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
lanes.forEach { lane -> LaneTile(lane) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaneTile(lane: Lane) {
|
||||
val directions = lane.directions.orEmpty()
|
||||
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
|
||||
val recommended = shown?.isRecommended == true
|
||||
Box(
|
||||
Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (recommended) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowUp,
|
||||
contentDescription = if (recommended) "Recommended lane" else "Lane",
|
||||
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
|
||||
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
||||
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
|
||||
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
||||
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
||||
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
|
||||
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
|
||||
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
||||
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
||||
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
|
||||
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
|
||||
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
|
||||
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
|
||||
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
|
||||
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
|
||||
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
|
||||
*/
|
||||
@Composable
|
||||
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val time = remainingTime(estimate.remainingTimeSeconds)
|
||||
val distance = estimate.remainingDistance.display()
|
||||
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||
val trip = estimate.tripText.text()
|
||||
if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return
|
||||
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.6f),
|
||||
contentColor = Color.White,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
val detail = listOfNotNull(
|
||||
arrival.takeIf { it.isNotEmpty() },
|
||||
distance.takeIf { it.isNotEmpty() },
|
||||
trip.takeIf { it.isNotEmpty() },
|
||||
).joinToString(" • ")
|
||||
|
||||
// The same solid card the manoeuvre card above it uses - one surface for the whole map
|
||||
// chrome, rather than this one spot styled on its own as a bar over the map.
|
||||
ChromeSurface(modifier, shadowElevation = 8.dp) {
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = estimate.remainingTimeColor.color(dark, Color.White),
|
||||
)
|
||||
}
|
||||
if (distance.isNotEmpty()) {
|
||||
Text(
|
||||
distance,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = estimate.remainingDistanceColor.color(dark, Color.White),
|
||||
)
|
||||
}
|
||||
if (trip.isNotEmpty()) {
|
||||
Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
||||
// Not a disconnect: the connection stays up and the app decides its own next screen.
|
||||
// Ending navigation is its own call, separate from a back press - a back press only
|
||||
// pops the on-screen card, leaving the app still believing it is navigating (so a
|
||||
// freshly picked destination silently does nothing); this is the call that actually
|
||||
// clears that flag on the app's side.
|
||||
GsIconButton(
|
||||
Icons.Filled.Close,
|
||||
"Stop navigating",
|
||||
{ CarServices.nav.stopNavigating() },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
size = 40.dp,
|
||||
iconSize = 20.dp,
|
||||
shape = CircleShape,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column {
|
||||
if (time.isNotEmpty()) {
|
||||
Text(
|
||||
time,
|
||||
style = ChromeType.headline,
|
||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
}
|
||||
if (detail.isNotEmpty()) {
|
||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,7 +354,10 @@ private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Mod
|
||||
template.mapController?.mapActionStrip,
|
||||
fullHeight = template.contentTemplate.needsFullHeight(),
|
||||
) {
|
||||
ContentTemplate(template.contentTemplate, Modifier)
|
||||
// A pane floating over the map is a place or a route the app is proposing, so its one
|
||||
// action - Start - counts itself down rather than waiting to be pressed; the same pane
|
||||
// filling a whole screen elsewhere gets no such push.
|
||||
ContentTemplate(template.contentTemplate, Modifier, autoStartPane = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +375,7 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
|
||||
template.header?.startHeaderAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
)
|
||||
if (template.pane != null) PaneRows(template.pane)
|
||||
if (template.pane != null) PaneRows(template.pane, autoStart = true)
|
||||
else ItemListColumn(template.itemList)
|
||||
}
|
||||
}
|
||||
@@ -322,13 +421,13 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
|
||||
)
|
||||
Box(Modifier.weight(1f, fill = false)) {
|
||||
if (template.isLoading) {
|
||||
Text("Finding routes…", Modifier.padding(14.dp))
|
||||
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
} else {
|
||||
ItemListColumn(template.itemList)
|
||||
}
|
||||
}
|
||||
template.navigateAction?.let {
|
||||
ActionRow(listOf(it), Modifier.padding(12.dp))
|
||||
AutoStartButton(it, Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,7 +445,7 @@ private fun ListPane(
|
||||
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||
Column {
|
||||
HeaderBar(title, startAction)
|
||||
if (loading) Text("Loading…", Modifier.padding(14.dp))
|
||||
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||
else ItemListColumn(itemList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -12,6 +15,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -20,6 +24,7 @@ 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
|
||||
@@ -27,8 +32,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.car.app.model.GridItem
|
||||
import androidx.car.app.model.GridTemplate
|
||||
import androidx.car.app.model.Item
|
||||
import androidx.car.app.model.ItemList
|
||||
@@ -51,6 +58,7 @@ import androidx.car.app.media.model.MediaPlaybackTemplate
|
||||
import androidx.car.app.OnDoneCallback
|
||||
import androidx.car.app.serialization.Bundleable
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.host.isDrawable
|
||||
import app.seb3thehacker.gearslip.host.inputChanged
|
||||
import app.seb3thehacker.gearslip.host.inputSubmitted
|
||||
import app.seb3thehacker.gearslip.host.submitted
|
||||
@@ -65,18 +73,24 @@ import app.seb3thehacker.gearslip.host.textChanged
|
||||
* the same function either way - the only difference is how much room they get.
|
||||
*/
|
||||
@Composable
|
||||
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) {
|
||||
fun ContentTemplate(
|
||||
template: Template?,
|
||||
modifier: Modifier = Modifier,
|
||||
autoStartPane: Boolean = false,
|
||||
ownActionsElsewhere: Boolean = false,
|
||||
tabId: String = "",
|
||||
) {
|
||||
when (template) {
|
||||
null -> Unit
|
||||
is ListTemplate -> ListContent(template, modifier)
|
||||
is GridTemplate -> GridContent(template, modifier)
|
||||
is PaneTemplate -> PaneContent(template, modifier)
|
||||
is PaneTemplate -> PaneContent(template, modifier, autoStartPane)
|
||||
is MessageTemplate -> MessageContent(template, modifier)
|
||||
is LongMessageTemplate -> LongMessageContent(template, modifier)
|
||||
is SearchTemplate -> SearchContent(template, modifier)
|
||||
is SignInTemplate -> SignInContent(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)
|
||||
else -> UnsupportedChrome(template, modifier)
|
||||
}
|
||||
@@ -126,7 +140,7 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
private fun PaneContent(template: PaneTemplate, modifier: Modifier, autoStart: Boolean = false) {
|
||||
ContentSurface(modifier) {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
@@ -134,7 +148,7 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.actionStrip,
|
||||
)
|
||||
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane)
|
||||
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane, autoStart = autoStart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,27 +157,45 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
||||
@Composable
|
||||
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
|
||||
ContentSurface(modifier) {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, template.title.text()),
|
||||
template.header?.startHeaderAction ?: template.headerAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.actionStrip,
|
||||
)
|
||||
// The header carries only the back arrow, not a title - a message template's own title
|
||||
// is usually the long-form question ("Would you like to download the map...") that
|
||||
// reads fine centred in the body but doesn't fit a one-line header without truncating.
|
||||
val backAction = template.header?.startHeaderAction ?: template.headerAction
|
||||
HeaderBar("", backAction, template.header?.endHeaderActions.orEmpty(), template.actionStrip)
|
||||
if (template.isLoading) {
|
||||
Loading()
|
||||
return@ContentSurface
|
||||
}
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// A back arrow already undoes this screen, so an app's own "Cancel" button would just
|
||||
// be a second way to do the one thing the header already offers.
|
||||
val actions = template.actions.orEmpty().filter { it.isDrawable() }.let { list ->
|
||||
if (backAction != null) list.filterNot { it.title.text().equals("Cancel", ignoreCase = true) } else list
|
||||
}
|
||||
Column(
|
||||
Modifier.weight(1f).fillMaxWidth().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
template.icon?.let {
|
||||
CarGlyph(it, Modifier.size(32.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
CarGlyph(it, Modifier.size(40.dp))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
Text(template.title.text(), style = ChromeType.title, textAlign = TextAlign.Center)
|
||||
val detail = template.message.text()
|
||||
if (detail.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
detail,
|
||||
style = ChromeType.body,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (actions.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
ActionRow(actions, Modifier.fillMaxWidth(0.6f), large = true)
|
||||
}
|
||||
Text(template.message.text(), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,7 +213,7 @@ private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier
|
||||
)
|
||||
Text(
|
||||
template.message.text(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
@@ -216,7 +248,15 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -236,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
|
||||
private fun SearchField(query: String, hint: String, modifier: Modifier = Modifier) {
|
||||
internal fun SearchField(
|
||||
query: String,
|
||||
hint: String,
|
||||
modifier: Modifier = Modifier,
|
||||
searchIcon: Boolean = true,
|
||||
onClear: (() -> Unit)? = null,
|
||||
) {
|
||||
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
modifier = modifier.fillMaxWidth().height(ICON_BUTTON_SIZE),
|
||||
) {
|
||||
Text(
|
||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
)
|
||||
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(
|
||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +356,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
||||
if (instructions.isNotEmpty()) {
|
||||
Text(
|
||||
instructions,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = ChromeType.body,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
@@ -298,7 +375,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
||||
if (additional.isNotEmpty()) {
|
||||
Text(
|
||||
additional,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
@@ -314,12 +391,12 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
|
||||
val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD
|
||||
|
||||
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()
|
||||
if (error.isNotEmpty()) {
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
@@ -343,8 +420,7 @@ private fun Callout(value: String, modifier: Modifier) {
|
||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = ChromeType.headline,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
@@ -355,43 +431,62 @@ private fun Callout(value: String, modifier: Modifier) {
|
||||
@Composable
|
||||
private fun TabContent(template: TabTemplate, modifier: Modifier) {
|
||||
val active = template.activeTabContentId
|
||||
ContentSurface(modifier) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.verticalScrollNone(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
template.headerAction?.let { ActionButton(it) }
|
||||
template.tabs.forEach { tab ->
|
||||
val selected = tab.contentId == active
|
||||
Surface(
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
modifier = Modifier.clickable {
|
||||
template.tabCallbackDelegate.tabSelected(tab.contentId)
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.verticalScrollNone(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
template.headerAction?.let { ActionButton(it) }
|
||||
template.tabs.forEach { tab ->
|
||||
val selected = tab.contentId == active
|
||||
GsIconBox(
|
||||
onClick = { template.tabCallbackDelegate.tabSelected(tab.contentId) },
|
||||
colors = if (selected) GsColors(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
else GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
latched = selected,
|
||||
) {
|
||||
tab.icon?.let {
|
||||
CarGlyph(it, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Row(
|
||||
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
tab.icon?.let {
|
||||
CarGlyph(it, Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(tab.title.text(), style = ChromeType.label, maxLines = 1)
|
||||
}
|
||||
Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (template.isLoading) Loading()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
if (template.isLoading) Loading()
|
||||
else ContentTemplate(template.tabContents?.template, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,46 +495,66 @@ private fun Modifier.verticalScrollNone(): Modifier = this
|
||||
|
||||
// --- 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
|
||||
* 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
|
||||
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) {
|
||||
// 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(
|
||||
headerTitle(template.header, ""),
|
||||
template.header?.startHeaderAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
template.header?.endHeaderActions.orEmpty() + if (showActionsInHeader) template.actions.orEmpty() else emptyList(),
|
||||
)
|
||||
if (template.isLoading) {
|
||||
Loading()
|
||||
return@ContentSurface
|
||||
}
|
||||
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||
template.sections.forEach { section ->
|
||||
template.sections.forEachIndexed { index, section ->
|
||||
PaneTitle(section.title.text())
|
||||
SectionRows(section)
|
||||
SectionRows(section, "$appPackage/$tabId/$index/${section.title.text()}")
|
||||
}
|
||||
}
|
||||
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionRows(section: Section<*>) {
|
||||
var items by remember(section) { mutableStateOf<List<Item>>(emptyList()) }
|
||||
private fun SectionRows(section: Section<*>, cacheKey: String) {
|
||||
var items by remember(section) { mutableStateOf(sectionCache.get(cacheKey).orEmpty()) }
|
||||
|
||||
LaunchedEffect(section) {
|
||||
if (sectionCache.get(cacheKey) != null) return@LaunchedEffect
|
||||
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
|
||||
runCatching {
|
||||
delegate.requestItemRange(0, size - 1, object : OnDoneCallback {
|
||||
override fun onSuccess(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
@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) =
|
||||
@@ -450,34 +565,62 @@ private fun SectionRows(section: Section<*>) {
|
||||
|
||||
if (items.isEmpty()) {
|
||||
val message = section.noItemsMessage.text()
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||
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 playback is drawn from the app's MediaSession rather than from the template, which
|
||||
* carries only a header. Gearslip has no media channel yet, so this says so plainly instead of
|
||||
* showing an empty player.
|
||||
* The template itself carries only a header - real Android Auto hosts draw this screen from the
|
||||
* app's MediaSession instead, which is exactly what [CarServices.media] already does for the
|
||||
* 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
|
||||
private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modifier) {
|
||||
val media = CarServices.media
|
||||
val now by media.now.collectAsState()
|
||||
ContentSurface(modifier) {
|
||||
HeaderBar(
|
||||
headerTitle(template.header, ""),
|
||||
template.header?.startHeaderAction,
|
||||
template.header?.endHeaderActions.orEmpty(),
|
||||
)
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
if (now.title.isNotEmpty()) {
|
||||
NowPlayingPage(now, media, Modifier.weight(1f).fillMaxWidth())
|
||||
} else {
|
||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
"Nothing playing yet.",
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -496,6 +639,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
||||
Text("Loading…", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("Loading…", style = ChromeType.title)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -82,19 +89,21 @@ import app.seb3thehacker.gearslip.host.text
|
||||
/**
|
||||
* The app keeps drawing its map across the whole surface - the visible area we report tells it
|
||||
* where to put its own markers, not where to stop painting. So anything the host lays over the
|
||||
* map is opaque by default, or the map reads through it.
|
||||
* map is opaque, always, in the same one colour: a card that lets the map show through in places
|
||||
* reads as broken glass, not as a deliberate choice, and a driver has no way to tell which was
|
||||
* meant. There is no alpha knob here on purpose - every card, bar and strip in the template
|
||||
* chrome shares this surface, so nothing drawn over a map is ever see-through.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ChromeSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
shape: Shape = RoundedCornerShape(16.dp),
|
||||
alpha: Float = 1f,
|
||||
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shadowElevation = shadowElevation,
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
shape = shape,
|
||||
modifier = modifier,
|
||||
@@ -102,6 +111,32 @@ internal fun ChromeSurface(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one type scale for everything a templated app's screen draws. Apps hand the host plain
|
||||
* strings with no size of their own - the Car App Library deliberately keeps that decision out of
|
||||
* their hands - so without a shared scale, each template's text ends up whatever size felt right
|
||||
* when that template was written, and two apps' otherwise-identical rows read as different sizes
|
||||
* of importance. Every car chrome file should reach for one of these four rather than naming a
|
||||
* Material size directly.
|
||||
*/
|
||||
internal object ChromeType {
|
||||
/** The one number or word a card exists to show: distance to a turn, minutes left, a PIN. */
|
||||
val headline: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)
|
||||
|
||||
/** A row's name, a header, a manoeuvre's instruction - the line the rest is supporting. */
|
||||
val title: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
|
||||
/** Supporting detail: a road, an address, a message's body, a second line under a title. */
|
||||
val body: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.titleMedium
|
||||
|
||||
/** The smallest text Gearslip draws over a map - still large enough to read while moving. */
|
||||
val label: androidx.compose.ui.text.TextStyle
|
||||
@Composable get() = MaterialTheme.typography.bodyLarge
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
|
||||
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
|
||||
@@ -109,7 +144,7 @@ internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier
|
||||
Text(
|
||||
"${template.javaClass.simpleName} isn't drawn yet",
|
||||
Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = ChromeType.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -142,8 +177,7 @@ internal fun HeaderBar(
|
||||
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = ChromeType.title,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -267,15 +301,17 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
||||
}
|
||||
val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
|
||||
|
||||
Surface(
|
||||
color = background,
|
||||
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) {
|
||||
GsIconBox(
|
||||
onClick = {
|
||||
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
|
||||
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
|
||||
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()) {
|
||||
// The label sits dead centre on the button; the icon hangs off the left edge, so the
|
||||
@@ -285,12 +321,7 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(title, style = ChromeType.title, maxLines = 1)
|
||||
}
|
||||
} else if (title.isEmpty()) {
|
||||
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
|
||||
@@ -304,12 +335,83 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
|
||||
if (hasGlyph) Spacer(Modifier.width(8.dp))
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
||||
Text(title, style = ChromeType.body, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A large action that fires on its own after five seconds, filling to show the countdown - for
|
||||
* the one button on a map-preview pane, where the app is proposing a single place or route and a
|
||||
* driver glancing at the car screen has already decided. Tapping it, same as any other button,
|
||||
* fires it at once; the fill is only ever a preview of what a touch already does.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
|
||||
var fraction by remember(action.identity()) { mutableStateOf(0f) }
|
||||
var fired by remember(action.identity()) { mutableStateOf(false) }
|
||||
fun fire() {
|
||||
if (fired) return
|
||||
fired = true
|
||||
action.onClickDelegate.click("auto-start")
|
||||
}
|
||||
androidx.compose.runtime.LaunchedEffect(action.identity()) {
|
||||
val steps = 60
|
||||
repeat(steps) { step ->
|
||||
kotlinx.coroutines.delay(AUTO_START_MS / steps)
|
||||
fraction = (step + 1) / steps.toFloat()
|
||||
}
|
||||
fire()
|
||||
}
|
||||
|
||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||
val background = action.backgroundColor.color(dark, MaterialTheme.colorScheme.primary)
|
||||
val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
|
||||
val hasGlyph = action.icon != null
|
||||
|
||||
GsIconBox(
|
||||
onClick = { fire() },
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
// 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.
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, content),
|
||||
enabled = action.isEnabled,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
) {
|
||||
androidx.compose.foundation.layout.Box {
|
||||
// The countdown itself: a plain fill sweeping left to right underneath the label.
|
||||
// matchParentSize(), not fillMaxHeight() - this Box sits beside a label Box that
|
||||
// wraps its own content, and the outer Box sizes itself to its tallest child; a
|
||||
// plain fillMaxHeight() here has nothing bounded to fill up to but the whole screen,
|
||||
// which is what was stretching the whole button that tall. matchParentSize() instead
|
||||
// takes whatever size the label ends up being, without itself voting on what that is.
|
||||
androidx.compose.foundation.layout.Box(Modifier.matchParentSize()) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.fillMaxWidth(fraction)
|
||||
.fillMaxHeight()
|
||||
.background(background),
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||
Text(
|
||||
action.title.text().ifEmpty { "Start" },
|
||||
style = ChromeType.title,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val AUTO_START_MS = 5_000L
|
||||
|
||||
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
||||
@Composable
|
||||
private fun AppIconBadge() {
|
||||
@@ -330,9 +432,16 @@ private fun standardFor(action: Action): Int? = when (action.type) {
|
||||
Action.TYPE_BACK -> CarIcon.TYPE_BACK
|
||||
Action.TYPE_PAN -> CarIcon.TYPE_PAN
|
||||
Action.TYPE_APP_ICON -> CarIcon.TYPE_APP_ICON
|
||||
ACTION_TYPE_NOW_PLAYING -> ACTION_TYPE_NOW_PLAYING
|
||||
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 ----------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -343,7 +452,14 @@ private fun standardFor(action: Action): Int? = when (action.type) {
|
||||
@Composable
|
||||
internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: Int? = null) {
|
||||
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) {
|
||||
// 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.
|
||||
@@ -364,6 +480,7 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
|
||||
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.
|
||||
CarIcon.TYPE_PAN -> Icons.Filled.Menu
|
||||
ACTION_TYPE_NOW_PLAYING -> MediaIcons.Equalizer
|
||||
else -> null
|
||||
} ?: return
|
||||
Icon(vector, contentDescription = null, modifier = modifier)
|
||||
@@ -376,8 +493,7 @@ internal fun PaneTitle(title: String) {
|
||||
if (title.isEmpty()) return
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = ChromeType.title,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
@@ -394,7 +510,7 @@ internal fun ItemListColumn(
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
val message = single?.noItemsMessage.text()
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||
return
|
||||
}
|
||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
||||
@@ -407,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
|
||||
internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
|
||||
@@ -419,47 +537,63 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(items) { item ->
|
||||
Column(
|
||||
Modifier
|
||||
.clickable { item.onClickDelegate.click("grid item") }
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CarGlyph(item.image, Modifier.size(36.dp))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
item.title.text(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = item.text.text()
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
items(items) { item -> GridTile(item, iconSize = 36.dp) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One cover-art tile: an app's own icon over a title and optional subtitle. Shared by
|
||||
* [GridItems] (a whole screen of tiles, [iconSize] scaled down there to suit a plain menu glyph)
|
||||
* and a section's horizontal shelf, which carries the same [GridItem] type but scrolls sideways
|
||||
* instead of wrapping into a grid and wants its full-size cover art.
|
||||
*/
|
||||
@Composable
|
||||
internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: androidx.compose.ui.unit.Dp = 144.dp) {
|
||||
Column(
|
||||
modifier
|
||||
.clickable { item.onClickDelegate.click("grid item") }
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CarGlyph(item.image, Modifier.size(iconSize))
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
item.title.text(),
|
||||
style = ChromeType.label,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = item.text.text()
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = ChromeType.label,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||
* driver glancing at the car screen has already decided. It never applies to more than one
|
||||
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
|
||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||
Column(modifier) {
|
||||
rows.forEach { RowItem(it, large = true) }
|
||||
ActionRow(
|
||||
pane?.actions.orEmpty(),
|
||||
Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
large = true,
|
||||
)
|
||||
if (autoStart && actions.size == 1) {
|
||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||
} else {
|
||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,18 +630,20 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea
|
||||
CarGlyph(it, Modifier.size(28.dp))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
||||
// type itself is the same scale as every other row, so a pane's one row and a plain
|
||||
// list's tenth row read as the same kind of text.
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
row.title.text(),
|
||||
style = if (large) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = if (large) FontWeight.SemiBold else null,
|
||||
style = ChromeType.title,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||
Text(
|
||||
line.text(),
|
||||
style = if (large) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
|
||||
style = ChromeType.body,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = if (large) 2 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
@@ -529,6 +665,7 @@ internal fun Color.luminanceIsDark(): Boolean =
|
||||
(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. */
|
||||
private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
|
||||
val pixels = IntArray(width * height)
|
||||
@@ -543,5 +680,5 @@ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
|
||||
return seen > 0
|
||||
}
|
||||
|
||||
private val ICON_BUTTON_SIZE = 54.dp
|
||||
internal val ICON_BUTTON_SIZE = 54.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)) {
|
||||
ScreenHeader("Vehicle data")
|
||||
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,381 @@
|
||||
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". The countdown runs out whatever it hears.
|
||||
*/
|
||||
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) { CarAssistant.stopListening(); close() } }, 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 {
|
||||
close()
|
||||
}
|
||||
},
|
||||
// No answer counts as no: the message has been heard, so the card goes.
|
||||
onError = { if (id == run) close() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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]. */
|
||||
private fun startCountdown(id: Int, ms: Long) {
|
||||
val sendAt = SystemClock.uptimeMillis() + ms
|
||||
_state.value = _state.value.copy(until = sendAt, span = ms, 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 never
|
||||
* pauses: in a noisy car it held for road noise and the radio, and the reply never went.
|
||||
*/
|
||||
private fun listenForCommand(id: Int) {
|
||||
CarAssistant.dictate(
|
||||
onPartial = {},
|
||||
cue = false,
|
||||
onResult = { text ->
|
||||
if (id != run) return@dictate
|
||||
when (command(text)) {
|
||||
Command.CANCEL -> {
|
||||
close()
|
||||
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: keep listening, so a
|
||||
// "cancel" after it still lands before the countdown runs out.
|
||||
null -> listenForCommand(id)
|
||||
}
|
||||
},
|
||||
// Silence ends the recognizer before the countdown does: listen again until it sends.
|
||||
onError = {
|
||||
if (id != run) return@dictate
|
||||
// A short gap, so a recognizer that fails at once can't spin.
|
||||
main.postDelayed({ if (id == run) listenForCommand(id) }, RELISTEN_MS)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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, and the message card closes. */
|
||||
fun cancel() {
|
||||
if (!active) return
|
||||
CarAssistant.stopListening()
|
||||
CarAssistant.stopSpeaking()
|
||||
close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends without sending and takes the message card with it. Without this the card's own popup
|
||||
* came back with a fresh bar, as if the message had just arrived.
|
||||
*/
|
||||
private fun close() {
|
||||
_state.value.target?.let { CarNotifications.dismissPopup(it.id) }
|
||||
finish()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
private const val RELISTEN_MS = 400L
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -84,11 +84,13 @@ object Weather {
|
||||
return
|
||||
}
|
||||
val data = runCatching { fetch(context, location) }
|
||||
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
|
||||
// Only the kind of error: some messages carry the request URL, and that holds the location.
|
||||
.onFailure { GearslipLog.w("weather fetch failed: ${it.javaClass.simpleName}") }
|
||||
.getOrNull()
|
||||
if (data != null) {
|
||||
flow.value = WeatherState.Ready(data)
|
||||
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
|
||||
// No place name: logs get shared in bug reports.
|
||||
GearslipLog.i("weather: loaded")
|
||||
}
|
||||
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
|
||||
}
|
||||
@@ -136,7 +138,7 @@ object Weather {
|
||||
// --- network --------------------------------------------------------------------------
|
||||
|
||||
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(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" +
|
||||
"¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" +
|
||||
|
||||
@@ -41,6 +41,7 @@ fun WeatherScreen() {
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
ScreenHeader("Weather")
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -15,16 +21,25 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
||||
* The bottom bar's Back walks the page history first and only leaves the app at the start.
|
||||
*
|
||||
* A page's own text inputs can't call up the phone's IME: the car UI lives in a Presentation on
|
||||
* a virtual display, and the platform's soft keyboard has nowhere sensible to appear there (see
|
||||
* [CarKeyboard]'s doc comment - every other text field in this app already draws its own for the
|
||||
* same reason). [FOCUS_TRACKING_JS] reports focus/blur on the page's editable elements, and
|
||||
* [CarKeyboard] edits the focused one back through injected JS instead of through the DOM's own
|
||||
* native input path, which this Presentation can't reach.
|
||||
*/
|
||||
@Composable
|
||||
fun WebApp(content: String) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val darkOutside = LocalDarkOutside.current
|
||||
var web by remember { mutableStateOf<WebView?>(null) }
|
||||
var fieldText by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
DisposableEffect(web) {
|
||||
navigator.backInterceptor = {
|
||||
@@ -39,32 +54,55 @@ fun WebApp(content: String) {
|
||||
onDispose { navigator.backInterceptor = null }
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient()
|
||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||
loadUrl(content)
|
||||
} else {
|
||||
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
// Back steps through the page's history first (the interceptor above), then leaves.
|
||||
ScreenHeader("Web", Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
|
||||
AndroidView(
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
settings.javaScriptEnabled = true
|
||||
settings.domStorageEnabled = true
|
||||
settings.loadWithOverviewMode = true
|
||||
settings.useWideViewPort = true
|
||||
webViewClient = LoggingClient()
|
||||
addJavascriptInterface(
|
||||
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||
"GearslipField",
|
||||
)
|
||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||
loadUrl(content)
|
||||
} else {
|
||||
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||
}
|
||||
web = this
|
||||
}
|
||||
web = this
|
||||
}
|
||||
},
|
||||
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
|
||||
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
},
|
||||
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
|
||||
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
||||
onRelease = { it.destroy() },
|
||||
)
|
||||
fieldText?.let { text ->
|
||||
CarKeyboard(
|
||||
text = text,
|
||||
onTextChange = { updated ->
|
||||
fieldText = updated
|
||||
web?.evaluateJavascript(setFieldValueJs(updated), null)
|
||||
},
|
||||
onSubmit = { web?.evaluateJavascript(SUBMIT_FIELD_JS, null) },
|
||||
onDismiss = {
|
||||
fieldText = null
|
||||
web?.evaluateJavascript(BLUR_FIELD_JS, null)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LoggingClient : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
GearslipLog.i("car web page loaded: $url")
|
||||
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
|
||||
}
|
||||
|
||||
// onPageFinished fires for error pages too, so without this a blocked load (missing
|
||||
@@ -81,3 +119,74 @@ private class LoggingClient : WebViewClient() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tells Kotlin when an editable element on the page gains or loses focus, so [CarKeyboard] can show. */
|
||||
private class FieldFocusBridge(private val onFocus: (String) -> Unit, private val onBlur: () -> Unit) {
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
// WebView calls into this from its own thread, never Compose's.
|
||||
@JavascriptInterface
|
||||
fun focused(value: String) { main.post { onFocus(value) } }
|
||||
|
||||
@JavascriptInterface
|
||||
fun blurred() { main.post { onBlur() } }
|
||||
}
|
||||
|
||||
// Plain `el.value = x` never reaches a React-controlled input: React replaces the native value
|
||||
// setter with its own to track edits, so a direct assignment sets the DOM attribute but leaves
|
||||
// React's own state (and anything bound to it, like a search box's query) unaware. Calling the
|
||||
// *native* setter through the prototype, the same trick browser extensions use, is what actually
|
||||
// notifies it once the 'input' event fires.
|
||||
private fun setFieldValueJs(value: String) = """
|
||||
(function() {
|
||||
var el = document.activeElement;
|
||||
if (!el) return;
|
||||
var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
||||
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||
if (setter && setter.set) { setter.set.call(el, ${JSONObject.quote(value)}); } else { el.value = ${JSONObject.quote(value)}; }
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
// 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
|
||||
// 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 = """
|
||||
(function() {
|
||||
var el = document.activeElement;
|
||||
if (!el) return;
|
||||
var enter = new KeyboardEvent('keydown', {
|
||||
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
|
||||
});
|
||||
var notHandled = el.dispatchEvent(enter);
|
||||
if (notHandled && el.form) {
|
||||
if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
|
||||
}
|
||||
})();
|
||||
"""
|
||||
|
||||
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
|
||||
private const val FOCUS_TRACKING_JS = """
|
||||
(function() {
|
||||
function isEditable(el) {
|
||||
if (!el) return false;
|
||||
var tag = el.tagName;
|
||||
if (tag === 'TEXTAREA') return true;
|
||||
if (el.isContentEditable) return true;
|
||||
if (tag !== 'INPUT') return false;
|
||||
var skip = ['button', 'submit', 'checkbox', 'radio', 'range', 'color', 'file', 'image', 'reset'];
|
||||
return skip.indexOf((el.type || 'text').toLowerCase()) === -1;
|
||||
}
|
||||
document.addEventListener('focusin', function(e) {
|
||||
if (isEditable(e.target)) window.GearslipField.focused(e.target.value || '');
|
||||
});
|
||||
document.addEventListener('focusout', function(e) {
|
||||
if (isEditable(e.target)) window.GearslipField.blurred();
|
||||
});
|
||||
})();
|
||||
"""
|
||||
@@ -0,0 +1,334 @@
|
||||
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,
|
||||
/** Also marks the control with the theme's hold marker, so the hold can be found. */
|
||||
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,
|
||||
) {
|
||||
if (onLongClick != null && enabled) GsHoldMarker(gsLook().holdMarker, c.container, shape, source)
|
||||
CompositionLocalProvider(LocalContentColor provides c.content) { content() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package app.seb3thehacker.gearslip.car.theme
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* How a control says it also does something when held. [GsIconBox] draws it on its own for any
|
||||
* control given an onLongClick, so callers never place it by hand.
|
||||
*/
|
||||
enum class HoldMarkerStyle {
|
||||
/** The top layer peels back from the top-right corner, further while held. Needs depth. */
|
||||
PEEL,
|
||||
|
||||
/** A plain dot in the top-right corner. It doesn't react to a hold. */
|
||||
DOT,
|
||||
|
||||
/** Nothing: the hold stays undiscovered. For themes that mark it some other way. */
|
||||
NONE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The hold marker for one control, drawn under its content. [container] is the control's own
|
||||
* colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the
|
||||
* dot whatever the theme says: a circle has no corner to peel.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) {
|
||||
if (style == HoldMarkerStyle.NONE) return
|
||||
val pressed by press.collectIsPressedAsState()
|
||||
val timeout = LocalViewConfiguration.current.longPressTimeoutMillis.toInt()
|
||||
val lift = remember { Animatable(0f) }
|
||||
if (style == HoldMarkerStyle.PEEL) {
|
||||
LaunchedEffect(pressed) {
|
||||
if (pressed) lift.animateTo(1f, tween(timeout, easing = LinearEasing)) else lift.animateTo(0f, tween(220))
|
||||
}
|
||||
}
|
||||
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val accent = MaterialTheme.colorScheme.primaryContainer
|
||||
val onAccent = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
val side = min(size.width, size.height)
|
||||
val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f
|
||||
val round = r >= side * 0.4f
|
||||
if (style == HoldMarkerStyle.DOT || round) {
|
||||
drawHoldDot(r, side, ink)
|
||||
} else {
|
||||
// Drawn for the bottom-right corner, then flipped up to the top-right.
|
||||
scale(1f, -1f, pivot = center) {
|
||||
drawPeel(r, side, container, accent, onAccent, lift.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Depth of a corner's curve along its diagonal: a fold shallower than this only shaves the curve. */
|
||||
private fun curveDepth(r: Float) = r * (2f - 1.41421f)
|
||||
|
||||
private fun DrawScope.drawHoldDot(r: Float, side: Float, ink: Color) {
|
||||
val dot = (side * 0.06f).coerceIn(5.dp.toPx(), 7.dp.toPx())
|
||||
// Tucked into the corner, clear of its curve.
|
||||
val inset = r * (1f - 0.7071f) + (side * 0.05f).coerceIn(4.dp.toPx(), 6.dp.toPx()) + dot / 2
|
||||
drawCircle(ink.copy(alpha = 0.6f), radius = dot / 2, center = Offset(size.width - inset, inset))
|
||||
}
|
||||
|
||||
/** The part of a rounded rectangle beyond the fold line x + y = c, as a closed outline. */
|
||||
private fun cornerRegion(w: Float, h: Float, r: Float, c: Float): List<Offset> {
|
||||
// Walk the bottom-right boundary: down the right edge, round the corner, along the bottom.
|
||||
val reach = (h + w - c) + 1f
|
||||
val edge = ArrayList<Offset>()
|
||||
edge += Offset(w, h - maxOf(r, reach))
|
||||
if (r > 0f) {
|
||||
for (i in 0..24) {
|
||||
val a = (i / 24f) * (PI / 2).toFloat()
|
||||
edge += Offset(w - r + r * cos(a), h - r + r * sin(a))
|
||||
}
|
||||
} else {
|
||||
edge += Offset(w, h)
|
||||
}
|
||||
edge += Offset(w - maxOf(r, reach), h)
|
||||
// Keep what lies beyond the fold, adding the exact crossing points.
|
||||
val out = ArrayList<Offset>()
|
||||
for (i in 0 until edge.size - 1) {
|
||||
val p = edge[i]
|
||||
val q = edge[i + 1]
|
||||
val ps = p.x + p.y - c
|
||||
val qs = q.x + q.y - c
|
||||
if (ps >= 0) out += p
|
||||
if ((ps >= 0) != (qs >= 0)) {
|
||||
val t = ps / (ps - qs)
|
||||
out += Offset(p.x + (q.x - p.x) * t, p.y + (q.y - p.y) * t)
|
||||
}
|
||||
}
|
||||
val last = edge.last()
|
||||
if (last.x + last.y - c >= 0) out += last
|
||||
return out
|
||||
}
|
||||
|
||||
private fun pathOf(points: List<Offset>): Path = Path().apply {
|
||||
if (points.isEmpty()) return@apply
|
||||
moveTo(points[0].x, points[0].y)
|
||||
for (p in points.drop(1)) lineTo(p.x, p.y)
|
||||
close()
|
||||
}
|
||||
|
||||
/**
|
||||
* The top layer peeling back from the corner, showing a second layer underneath with the same
|
||||
* rounded corner, so the control's outline never changes. Sized from the control: a launcher
|
||||
* tile folds about 27dp at rest and 64dp held, a nav bar button far less.
|
||||
*/
|
||||
private fun DrawScope.drawPeel(r: Float, side: Float, face: Color, accent: Color, onAccent: Color, lift: Float) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val rest = curveDepth(r) + (side * 0.1f).coerceIn(4.dp.toPx(), 11.dp.toPx())
|
||||
val held = rest + min(37.dp.toPx(), side * 0.35f)
|
||||
val e = rest + (held - rest) * lift
|
||||
val c = w + h - e
|
||||
val region = cornerRegion(w, h, r, c)
|
||||
if (region.size < 3) return
|
||||
val mid = Offset(w - e / 2, h - e / 2)
|
||||
// 1. The second layer, turning the accent colour as the hold completes.
|
||||
drawPath(pathOf(region), lerp(lerp(face, Color.Black, 0.26f), accent, lift))
|
||||
// The lifted edge of the top layer shades the second layer just beside it.
|
||||
drawPath(
|
||||
pathOf(region),
|
||||
Brush.linearGradient(
|
||||
listOf(Color.Black.copy(alpha = 0.35f), Color.Transparent),
|
||||
start = mid,
|
||||
end = mid + Offset(e * 0.25f, e * 0.25f),
|
||||
),
|
||||
)
|
||||
// 2. "More" waits on the second layer, readable once the peel is well back.
|
||||
if (lift > 0.35f && side > 80.dp.toPx()) {
|
||||
val a = ((lift - 0.35f) / 0.65f).coerceIn(0f, 1f)
|
||||
val centre = Offset(w - e * 0.3f, h - e * 0.3f)
|
||||
val gap = 5.5.dp.toPx()
|
||||
for (k in -1..1) {
|
||||
drawCircle(onAccent.copy(alpha = a), radius = 2.2.dp.toPx(), center = centre + Offset(k * gap * 0.71f, -k * gap * 0.71f))
|
||||
}
|
||||
}
|
||||
// 3. The flap: the same corner folded back over the face, its rounded tip and all.
|
||||
val flap = region.map { Offset(c - it.y, c - it.x) }
|
||||
translate(-1.5.dp.toPx(), -1.5.dp.toPx()) {
|
||||
drawPath(pathOf(flap), Color.Black.copy(alpha = 0.30f))
|
||||
}
|
||||
drawPath(
|
||||
pathOf(flap),
|
||||
Brush.linearGradient(
|
||||
listOf(lerp(face, Color.White, 0.30f), lerp(face, Color.White, 0.08f)),
|
||||
start = Offset(c - h, c - w),
|
||||
end = mid,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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,121 @@
|
||||
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,
|
||||
/**
|
||||
* How a control with a press-and-hold action says so. The peel needs a raised face to lift
|
||||
* from; a flat look takes the dot.
|
||||
*/
|
||||
val holdMarker: HoldMarkerStyle = HoldMarkerStyle.PEEL,
|
||||
/** Replaces this look when the UI is light, for effects that only work on one background. */
|
||||
val light: ControlLook? = null,
|
||||
)
|
||||
|
||||
object GsThemes {
|
||||
val Keycap = GsTheme(
|
||||
id = "keycap",
|
||||
name = "Keycap",
|
||||
control = ControlLook(lip = 4.dp, pressedScale = 1f, holdMarker = HoldMarkerStyle.PEEL),
|
||||
)
|
||||
|
||||
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,
|
||||
holdMarker = HoldMarkerStyle.PEEL,
|
||||
// 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,
|
||||
holdMarker = HoldMarkerStyle.PEEL,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val Flat = GsTheme(
|
||||
id = "flat",
|
||||
name = "Flat",
|
||||
control = ControlLook(holdMarker = HoldMarkerStyle.DOT),
|
||||
)
|
||||
|
||||
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 }
|
||||
@@ -1,11 +1,19 @@
|
||||
package app.seb3thehacker.gearslip.host
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
/**
|
||||
* Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries
|
||||
* (there are many: one per screen it registers) are noise in every list of apps to open.
|
||||
*/
|
||||
object AndroidAuto {
|
||||
|
||||
/** Kept in sync with the `<queries><package>` entries in the manifest: package visibility
|
||||
* hides an app's presence entirely unless one names it (or a broader query matches it), and
|
||||
* this one has no launcher icon on current builds for a launcher-category query to find. */
|
||||
private val KNOWN_PACKAGES = listOf("com.google.android.projection.gearhead")
|
||||
|
||||
private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto")
|
||||
|
||||
fun matches(packageName: String, label: String): Boolean {
|
||||
@@ -14,4 +22,26 @@ object AndroidAuto {
|
||||
val name = label.lowercase().replace(Regex("[^a-z ]"), " ")
|
||||
return "android auto" in name || "androidauto" in name.replace(" ", "")
|
||||
}
|
||||
|
||||
/** The package name of Google's Android Auto, if it is installed. */
|
||||
fun installedPackage(context: Context): String? {
|
||||
val manager = context.packageManager
|
||||
return KNOWN_PACKAGES.firstOrNull { pkg ->
|
||||
runCatching { manager.getPackageInfo(pkg, 0) }.isSuccess
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [packageName] can still fight Gearslip for the USB connection: installed, and not
|
||||
* already disabled for this user. `ApplicationInfo.enabled` only reflects the manifest's
|
||||
* static default, not a driver's own Settings toggle, so this reads the actual runtime
|
||||
* setting instead - a driver who disabled it instead of uninstalling it (the app info screen
|
||||
* offers both) has already done what this step asks.
|
||||
*/
|
||||
fun isActive(context: Context, packageName: String): Boolean {
|
||||
val setting = runCatching { context.packageManager.getApplicationEnabledSetting(packageName) }
|
||||
.getOrDefault(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)
|
||||
return setting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT ||
|
||||
setting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ object CarAppCatalog {
|
||||
.asSequence()
|
||||
.filter { it.serviceInfo.packageName != context.packageName }
|
||||
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
||||
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
|
||||
.map { resolved ->
|
||||
val info = resolved.serviceInfo
|
||||
TemplateApp(
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.content.ServiceConnection
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.location.Location
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
@@ -31,6 +32,7 @@ import androidx.car.app.suggestion.ISuggestionHost
|
||||
import androidx.car.app.hardware.ICarHardwareHost
|
||||
import androidx.car.app.hardware.ICarHardwareResult
|
||||
import androidx.car.app.navigation.INavigationHost
|
||||
import androidx.car.app.navigation.INavigationManager
|
||||
import androidx.car.app.serialization.Bundleable
|
||||
import androidx.car.app.versioning.CarAppApiLevels
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
@@ -81,6 +83,8 @@ class CarAppConnection(private val context: Context) {
|
||||
|
||||
private var carApp: ICarApp? = null
|
||||
private var appManager: IAppManager? = null
|
||||
/** Only present for an app that asked for it - most apps aren't navigation apps. */
|
||||
private var navigationManager: INavigationManager? = null
|
||||
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
||||
private var surfaceCallback: ISurfaceCallback? = null
|
||||
private var lent: Lent? = null
|
||||
@@ -91,18 +95,27 @@ class CarAppConnection(private val context: Context) {
|
||||
/** Whose icon the header shows for the standard app-icon action. */
|
||||
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 frameHeight = 480
|
||||
private var frameDensity = 160
|
||||
|
||||
/** A destination to navigate to as soon as the app is created; see [navigateTo]. */
|
||||
private var pendingDestination: String? = null
|
||||
|
||||
// --- connection ---------------------------------------------------------------------
|
||||
|
||||
fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int) {
|
||||
disconnect()
|
||||
fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int, destination: String? = null) {
|
||||
// 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
|
||||
frameHeight = height
|
||||
frameDensity = densityDpi
|
||||
component = app.component
|
||||
pendingDestination = destination
|
||||
update { Status(phase = Phase.BINDING, app = app.label) }
|
||||
|
||||
val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component)
|
||||
@@ -122,11 +135,23 @@ class CarAppConnection(private val context: Context) {
|
||||
}
|
||||
binding = connection
|
||||
|
||||
LocationKeepAlive.start(context)
|
||||
val bind = {
|
||||
val bind = bind@{
|
||||
// Replaced by another connect (or a disconnect) while waiting on the location service.
|
||||
if (binding !== connection) return@bind
|
||||
var bindError: Throwable? = null
|
||||
val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES
|
||||
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 {
|
||||
GearslipLog.e("bindService threw for ${app.component}", it)
|
||||
bindError = it
|
||||
@@ -140,9 +165,20 @@ class CarAppConnection(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
bind()
|
||||
// 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()
|
||||
} 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
|
||||
* name one that only Google's own Android Auto host holds, and no other app can be granted.
|
||||
@@ -176,7 +212,9 @@ class CarAppConnection(private val context: Context) {
|
||||
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 = null
|
||||
val carApp = this.carApp
|
||||
@@ -184,14 +222,19 @@ class CarAppConnection(private val context: Context) {
|
||||
runCatching { carApp.onAppPause(noop("onAppPause")) }
|
||||
runCatching { carApp.onAppStop(noop("onAppStop")) }
|
||||
}
|
||||
LocationKeepAlive.stop(context)
|
||||
if (holdsLocation && !keepLocation) {
|
||||
holdsLocation = false
|
||||
LocationKeepAlive.release(context)
|
||||
}
|
||||
binding?.let { runCatching { context.unbindService(it) } }
|
||||
binding = null
|
||||
this.carApp = null
|
||||
appManager = null
|
||||
navigationManager = null
|
||||
_panMode.value = false
|
||||
surfaceCallback = null
|
||||
component = null
|
||||
pendingDestination = null
|
||||
_template.value = null
|
||||
update { Status() }
|
||||
}
|
||||
@@ -238,12 +281,34 @@ class CarAppConnection(private val context: Context) {
|
||||
private fun onHandshakeAccepted() {
|
||||
val app = carApp ?: return
|
||||
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() }) {
|
||||
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() {
|
||||
val app = carApp ?: return
|
||||
GearslipLog.i("host: created; starting")
|
||||
@@ -269,6 +334,37 @@ class CarAppConnection(private val context: Context) {
|
||||
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
|
||||
requestTemplate()
|
||||
}) { app.getManager(CarContext.APP_SERVICE, it) }
|
||||
|
||||
// Best-effort and silent: an app that never asked for NavigationManager (not every
|
||||
// templated app navigates) simply has no binder to hand back, which is normal, not a
|
||||
// failure worth surfacing.
|
||||
runCatching {
|
||||
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
|
||||
override fun onSuccess(response: Bundleable?) {
|
||||
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||
navigationManager = when (value) {
|
||||
is INavigationManager -> value
|
||||
is IBinder -> INavigationManager.Stub.asInterface(value)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
override fun onFailure(response: Bundleable?) = Unit
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the app itself to stop navigating - the proper end of a route, not just leaving its
|
||||
* screen. Ending only the on-screen guidance (a back press) leaves the app's own
|
||||
* "am I navigating" flag set, which is what stopped a fresh route from starting afterwards;
|
||||
* this is the call [NavigationManagerCallback.onStopNavigation] on the app's side answers,
|
||||
* and it is expected to clear that flag and present its own post-navigation screen.
|
||||
*/
|
||||
fun stopNavigating() {
|
||||
val manager = navigationManager ?: return
|
||||
GearslipLog.i("host: asking the app to stop navigating")
|
||||
runCatching { manager.onStopNavigation(noop("stop navigation")) }
|
||||
.onFailure { GearslipLog.w("host: could not stop navigation: ${it.message}") }
|
||||
}
|
||||
|
||||
/** The app describes its current screen; this is what the host draws. */
|
||||
@@ -343,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 {
|
||||
val current = lent ?: return@post
|
||||
if (surface != null && current.surface !== surface) return@post
|
||||
lent = null
|
||||
sendSurfaceDestroyed(current)
|
||||
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() {
|
||||
val callback = surfaceCallback ?: return
|
||||
val surface = lent ?: return
|
||||
@@ -377,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
|
||||
// chrome, and the stable area is the part that never gets covered. We keep our chrome
|
||||
// to a top bar, so both are the surface minus that bar - reported by the UI via
|
||||
// [reportAreas] once it has measured. Until then the whole surface is fair game.
|
||||
// chrome, and the stable area is the part that never gets covered. Both depend on the
|
||||
// template (see visibleAreaFor) and are reported by the UI via [reportAreas] once it
|
||||
// has measured. Until then the whole surface is fair game.
|
||||
val whole = Rect(0, 0, surface.width, surface.height)
|
||||
reportAreas(whole, whole)
|
||||
|
||||
@@ -629,5 +756,8 @@ class CarAppConnection(private val context: Context) {
|
||||
private companion object {
|
||||
/** The newest template API this host claims to understand. */
|
||||
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 app.seb3thehacker.gearslip.GearslipLog
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
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.
|
||||
*
|
||||
@@ -41,10 +52,50 @@ fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.to
|
||||
*/
|
||||
fun CarIcon?.image(context: Context): ImageBitmap? {
|
||||
val icon = this?.icon ?: return null
|
||||
val drawable: Drawable = runCatching { icon.loadDrawable(context) }.getOrNull() ?: return null
|
||||
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX
|
||||
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX
|
||||
return runCatching { drawable.toBitmap(width, height).asImageBitmap() }.getOrNull()
|
||||
val key = icon.cacheKey(context)
|
||||
iconCache.get(key)?.let { return it }
|
||||
val drawable: Drawable = runCatching { icon.loadDrawable(context) }
|
||||
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
||||
.getOrNull()
|
||||
?: 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. */
|
||||
@@ -64,9 +115,16 @@ fun CarColor?.color(dark: Boolean, default: Color): Color = when (this?.type) {
|
||||
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 {
|
||||
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) {
|
||||
Distance.UNIT_METERS -> "m"
|
||||
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km"
|
||||
@@ -83,6 +141,32 @@ fun Distance?.display(): String {
|
||||
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. */
|
||||
fun remainingTime(seconds: Long): String {
|
||||
if (seconds <= 0) return ""
|
||||
@@ -91,6 +175,17 @@ fun remainingTime(seconds: Long): String {
|
||||
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
|
||||
}
|
||||
|
||||
/** "1:10 AM" - the clock at the destination, not the phone's own, in case a route crosses a zone. */
|
||||
fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
|
||||
val at = this ?: return ""
|
||||
val shifted = java.util.Date(at.timeSinceEpochMillis + at.zoneOffsetSeconds * 1000L)
|
||||
return runCatching {
|
||||
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}.format(shifted)
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the app something was tapped.
|
||||
*
|
||||
|
||||
@@ -14,7 +14,67 @@ object KnownApps {
|
||||
"app.organicmaps", // Organic Maps: map, search, routing, settings
|
||||
"net.osmand.plus", // OsmAnd: map, search, routing, settings
|
||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
||||
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
||||
"com.metrolist.music", // Metrolist: browse, play, works
|
||||
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||
)
|
||||
|
||||
fun works(packageName: String): Boolean = packageName in working
|
||||
|
||||
/**
|
||||
* 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
|
||||
* launcher entirely rather than shown as a tile that goes nowhere.
|
||||
*/
|
||||
private val hidden = setOf(
|
||||
"com.google.android.dialer", // Google Phone
|
||||
"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
|
||||
|
||||
/** Shown on the launcher with a red X: known broken, but expected enough that hiding it outright would look like a bug. */
|
||||
private val broken = setOf(
|
||||
"com.google.android.apps.messaging", // Google Messages
|
||||
"com.waze", // Waze
|
||||
"com.generalmagic.magicearth", // Magic Earth: never gets past connecting
|
||||
)
|
||||
|
||||
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. Wins over [works] and [playerWorks] for the badge.
|
||||
*/
|
||||
private val partial = setOf(
|
||||
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
|
||||
"com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly
|
||||
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first
|
||||
"deezer.android.app", // Deezer: the same - it has to be opened on the phone first
|
||||
)
|
||||
|
||||
fun isPartial(packageName: String): Boolean = packageName in partial
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Handler
|
||||
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.
|
||||
@@ -34,10 +37,29 @@ class LocationKeepAlive : Service() {
|
||||
.setContentTitle("Gearslip is sharing location with the car map")
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
|
||||
// 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)
|
||||
}.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
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (state == State.ON) state = State.OFF
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
enum class State { OFF, STARTING, ON, FAILED }
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL = "gearslip_location"
|
||||
private const val NOTIFICATION_ID = 44
|
||||
@@ -45,14 +67,73 @@ class LocationKeepAlive : Service() {
|
||||
/** Public flag value; the constant itself is hidden from the SDK. */
|
||||
const val BIND_INCLUDE_CAPABILITIES = 0x1000
|
||||
|
||||
fun start(context: Context) {
|
||||
val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
if (granted) runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) }
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
@Volatile var state = State.OFF
|
||||
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) {
|
||||
context.stopService(Intent(context, LocationKeepAlive::class.java))
|
||||
/** 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))
|
||||
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,10 +2,17 @@ package app.seb3thehacker.gearslip.media
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadata
|
||||
import android.media.Rating
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
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.session.MediaController
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
@@ -14,6 +21,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -31,7 +39,17 @@ class MediaEntry(
|
||||
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. */
|
||||
class QueueTrack(
|
||||
val queueId: Long,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val iconUri: Uri?,
|
||||
val iconBitmap: Bitmap?,
|
||||
)
|
||||
|
||||
/** What is playing, as the car's now-playing panel needs it. */
|
||||
class NowPlaying(
|
||||
@@ -48,7 +66,45 @@ class NowPlaying(
|
||||
val actions: Long = 0,
|
||||
val custom: List<CustomAction> = emptyList(),
|
||||
val error: String? = null,
|
||||
/** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */
|
||||
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) }
|
||||
}
|
||||
|
||||
/** The app says it takes searches. Spotify says so and then ignores Gearslip's, so [CarMedia.search] checks. */
|
||||
val canSearch get() = (actions and PlaybackState.ACTION_PLAY_FROM_SEARCH) != 0L
|
||||
|
||||
/** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
|
||||
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 hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
|
||||
|
||||
@@ -92,7 +148,7 @@ class CarMedia(private val context: Context) {
|
||||
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
||||
|
||||
/** 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)
|
||||
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
||||
@@ -109,8 +165,68 @@ class CarMedia(private val context: Context) {
|
||||
private val _now = MutableStateFlow(NowPlaying())
|
||||
val now: StateFlow<NowPlaying> = _now.asStateFlow()
|
||||
|
||||
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
||||
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
|
||||
|
||||
/** A typed search on its way to the app; [Search.ignored] once it has played nothing for it. */
|
||||
class Search(val query: String, val ignored: Boolean = false)
|
||||
|
||||
/**
|
||||
* What the app's own library search found for [query]. [entries] is null while it looks.
|
||||
* [supported] is false when the app has no library search, so the panel can fall back to
|
||||
* "play the best match" (see [search]).
|
||||
*/
|
||||
class Results(val query: String, val entries: List<MediaEntry>? = null, val supported: Boolean = true)
|
||||
|
||||
private val _results = MutableStateFlow<Results?>(null)
|
||||
val results: StateFlow<Results?> = _results.asStateFlow()
|
||||
private var resultsQuery: String? = null
|
||||
|
||||
private val _search = MutableStateFlow<Search?>(null)
|
||||
val search: StateFlow<Search?> = _search.asStateFlow()
|
||||
private var searchBefore: NowPlaying? = null
|
||||
|
||||
/** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
|
||||
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 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 rootId: String? = null
|
||||
private val path = ArrayDeque<Pair<String, String>>() // id to title
|
||||
@@ -144,7 +260,7 @@ class CarMedia(private val context: Context) {
|
||||
* is out of reach. Reading another app's session needs Notification access, which the driver
|
||||
* 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
|
||||
val manager = context.getSystemService(MediaSessionManager::class.java)
|
||||
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
||||
@@ -157,32 +273,34 @@ class CarMedia(private val context: Context) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
if (attempt < SESSION_RETRIES) {
|
||||
main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS)
|
||||
if (attempt < limit) {
|
||||
main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared")
|
||||
controller = session.also {
|
||||
it.registerCallback(callback)
|
||||
update(it.metadata, it.playbackState)
|
||||
}
|
||||
attach(session)
|
||||
_browse.value = BrowseState(loading = false, unavailable = true)
|
||||
_phase.value = Phase.READY
|
||||
}
|
||||
|
||||
private fun onConnected(app: MediaApp) {
|
||||
val mb = browser ?: return
|
||||
searchComponent = app.component
|
||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||
controller = MediaController(context, mb.sessionToken).also {
|
||||
it.registerCallback(callback)
|
||||
update(it.metadata, it.playbackState)
|
||||
}
|
||||
attach(MediaController(context, mb.sessionToken))
|
||||
rootId = mb.root
|
||||
path.clear()
|
||||
_phase.value = Phase.READY
|
||||
@@ -192,6 +310,20 @@ class CarMedia(private val context: Context) {
|
||||
private val callback = object : MediaController.Callback() {
|
||||
override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState)
|
||||
override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state)
|
||||
override fun onQueueChanged(queue: MutableList<MediaSession.QueueItem>?) {
|
||||
_queue.value = queueOf(queue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueOf(items: List<MediaSession.QueueItem>?): List<QueueTrack> = items.orEmpty().map { item ->
|
||||
val d = item.description
|
||||
QueueTrack(
|
||||
queueId = item.queueId,
|
||||
title = d.title?.toString().orEmpty(),
|
||||
subtitle = d.subtitle?.toString().orEmpty(),
|
||||
iconUri = d.iconUri,
|
||||
iconBitmap = d.iconBitmap,
|
||||
)
|
||||
}
|
||||
|
||||
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
|
||||
@@ -210,9 +342,14 @@ class CarMedia(private val context: Context) {
|
||||
speed = state?.playbackSpeed ?: 1f,
|
||||
updatedAt = state?.lastPositionUpdateTime ?: 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(),
|
||||
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,
|
||||
)
|
||||
checkSearch()
|
||||
}
|
||||
|
||||
// --- browsing ------------------------------------------------------------------------
|
||||
@@ -286,10 +423,181 @@ class CarMedia(private val context: Context) {
|
||||
fun previous() = controller?.transportControls?.skipToPrevious()
|
||||
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
|
||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
||||
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
||||
|
||||
/**
|
||||
* Asks the app to find and play [query]: a song, artist, album or playlist. This goes through
|
||||
* the app's playback session, so it works for apps that keep their library from Gearslip, and
|
||||
* the app stays in the background. Apps may still ignore it (Spotify only takes Google's), so
|
||||
* if nothing new plays within [SEARCH_WAIT_MS] the search is marked ignored.
|
||||
*/
|
||||
fun search(query: String) {
|
||||
val c = controller ?: return
|
||||
main.removeCallbacks(searchTimeout)
|
||||
searchBefore = _now.value
|
||||
_search.value = Search(query)
|
||||
GearslipLog.i("media: -> playFromSearch")
|
||||
c.transportControls.playFromSearch(query, Bundle())
|
||||
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the app's library for matches to [query], to list for the driver to pick from. Unlike
|
||||
* [search], nothing plays until one is chosen, so a wrong guess costs nothing. Metrolist and
|
||||
* ViVi answer with songs from the library first, then YouTube Music.
|
||||
*/
|
||||
fun searchLibrary(query: String) {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) {
|
||||
clearResults()
|
||||
return
|
||||
}
|
||||
val component = searchComponent
|
||||
if (component == null) {
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
return
|
||||
}
|
||||
resultsQuery = q
|
||||
_results.value = Results(q)
|
||||
val sb = searchBrowser
|
||||
if (sb != null && sb.isConnected) {
|
||||
runSearch(sb, q)
|
||||
return
|
||||
}
|
||||
if (sb != null) return // still connecting; it searches for [resultsQuery] once connected
|
||||
// The framework MediaBrowser has no search; the support library's does, over the same
|
||||
// service. Opened on first use only, so apps nobody searches never see a second client.
|
||||
lateinit var created: MediaBrowserCompat
|
||||
created = MediaBrowserCompat(context, component, object : MediaBrowserCompat.ConnectionCallback() {
|
||||
override fun onConnected() {
|
||||
resultsQuery?.let { runSearch(created, it) }
|
||||
}
|
||||
|
||||
override fun onConnectionFailed() {
|
||||
GearslipLog.w("media: no library connection for search")
|
||||
searchBrowser = null
|
||||
resultsQuery?.let { _results.value = Results(it, emptyList(), supported = false) }
|
||||
}
|
||||
}, null)
|
||||
searchBrowser = created
|
||||
runCatching { created.connect() }.onFailure {
|
||||
searchBrowser = null
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runSearch(sb: MediaBrowserCompat, q: String) {
|
||||
runCatching {
|
||||
sb.search(q, Bundle(), object : MediaBrowserCompat.SearchCallback() {
|
||||
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
|
||||
if (query != resultsQuery) return
|
||||
GearslipLog.i("media: library search found ${items.size}")
|
||||
_results.value = Results(query, items.map(::entryOfCompat))
|
||||
}
|
||||
|
||||
override fun onError(query: String, extras: Bundle?) {
|
||||
if (query != resultsQuery) return
|
||||
GearslipLog.i("media: the app has no library search")
|
||||
_results.value = Results(query, emptyList(), supported = false)
|
||||
}
|
||||
})
|
||||
}.onFailure {
|
||||
GearslipLog.w("media: library search failed: ${it.message}")
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun entryOfCompat(item: MediaBrowserCompat.MediaItem): MediaEntry {
|
||||
val d = item.description
|
||||
return MediaEntry(
|
||||
id = item.mediaId.orEmpty(),
|
||||
title = d.title?.toString().orEmpty(),
|
||||
subtitle = d.subtitle?.toString().orEmpty(),
|
||||
iconUri = d.iconUri,
|
||||
iconBitmap = d.iconBitmap,
|
||||
browsable = item.isBrowsable,
|
||||
playable = item.isPlayable,
|
||||
)
|
||||
}
|
||||
|
||||
/** The connected app's service, for the search connection; null when nothing is connected. */
|
||||
private var searchComponent: android.content.ComponentName? = null
|
||||
private var searchBrowser: MediaBrowserCompat? = null
|
||||
|
||||
fun clearResults() {
|
||||
resultsQuery = null
|
||||
_results.value = null
|
||||
}
|
||||
|
||||
/** Whether there's a library to search through, the better of the two searches. */
|
||||
val canSearchLibrary: Boolean get() = browser?.isConnected == true && searchComponent != null
|
||||
|
||||
fun dismissSearch() {
|
||||
main.removeCallbacks(searchTimeout)
|
||||
main.removeCallbacks(clearSearch)
|
||||
_search.value = null
|
||||
}
|
||||
|
||||
private val searchTimeout = Runnable {
|
||||
val pending = _search.value ?: return@Runnable
|
||||
if (pending.ignored) return@Runnable
|
||||
GearslipLog.w("media: nothing new played after a search")
|
||||
_search.value = Search(pending.query, ignored = true)
|
||||
main.postDelayed(clearSearch, SEARCH_NOTICE_MS)
|
||||
}
|
||||
|
||||
private val clearSearch = Runnable { _search.value = null }
|
||||
|
||||
/** Something new is playing: another track, or the same one started from a pause. */
|
||||
private fun checkSearch() {
|
||||
val pending = _search.value ?: return
|
||||
val before = searchBefore ?: return
|
||||
val now = _now.value
|
||||
if (pending.ignored || !now.playing) return
|
||||
if (now.title != before.title || !before.playing) {
|
||||
GearslipLog.i("media: search started playback")
|
||||
dismissSearch()
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's own like button if it has one, since that's the one its library listens to; else a heart rating. */
|
||||
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() {
|
||||
connecting = null
|
||||
runCatching { searchBrowser?.disconnect() }
|
||||
searchBrowser = null
|
||||
searchComponent = null
|
||||
clearResults()
|
||||
main.removeCallbacksAndMessages(null)
|
||||
runCatching { compat?.unregisterCallback(compatCallback) }
|
||||
compat = null
|
||||
_modes.value = Modes()
|
||||
runCatching { controller?.unregisterCallback(callback) }
|
||||
runCatching { subscribed?.let { browser?.unsubscribe(it) } }
|
||||
runCatching { browser?.disconnect() }
|
||||
@@ -298,10 +606,45 @@ class CarMedia(private val context: Context) {
|
||||
subscribed = null
|
||||
_phase.value = Phase.IDLE
|
||||
_now.value = NowPlaying()
|
||||
_queue.value = emptyList()
|
||||
dismissSearch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens [app] on the phone, then connects once it publishes its session. The driver asks for
|
||||
* this from the "Open" button a session-only app (Spotify) shows when it isn't running yet.
|
||||
*/
|
||||
fun launchAndConnect(app: MediaApp) {
|
||||
if (!openApp(app)) return
|
||||
disconnect()
|
||||
connecting = app
|
||||
_phase.value = Phase.CONNECTING
|
||||
// A cold start takes a few seconds to publish a session, so wait, then watch for longer.
|
||||
main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS)
|
||||
}
|
||||
|
||||
/** Brings [app] to the foreground on the phone so it publishes its session. */
|
||||
private fun openApp(app: MediaApp): Boolean {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(app.component.packageName) ?: return false
|
||||
return runCatching {
|
||||
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
GearslipLog.i("media: opened ${app.label} to bring up its session")
|
||||
true
|
||||
}.getOrElse {
|
||||
GearslipLog.w("media: could not open ${app.label}: ${it.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SESSION_RETRIES = 6
|
||||
const val SESSION_RETRY_MS = 500L
|
||||
/** After opening a cold app, how long to wait before the first look for its session. */
|
||||
const val LAUNCH_WAIT_MS = 1_500L
|
||||
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
||||
const val SESSION_RETRIES_LAUNCHED = 16
|
||||
/** How long an app gets to start playing a search before Gearslip says it didn't. */
|
||||
const val SEARCH_WAIT_MS = 6_000L
|
||||
const val SEARCH_NOTICE_MS = 6_000L
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
import java.security.MessageDigest
|
||||
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
|
||||
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
|
||||
val key = "$title|$artist|$album|$durationMs"
|
||||
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) {
|
||||
runCatching {
|
||||
exact(title, artist, album, durationMs) ?: search(title, artist)
|
||||
}.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull()
|
||||
}
|
||||
synchronized(cache) { cache[key] = result }
|
||||
if (result != null) withContext(Dispatchers.IO) { writeDisk(context, key, result) }
|
||||
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? {
|
||||
if (artist.isBlank()) return null
|
||||
val query = buildString {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.media
|
||||
|
||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||
import app.seb3thehacker.gearslip.host.CarDescriptor
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -18,22 +20,21 @@ class MediaApp(
|
||||
/**
|
||||
* Finds media apps that opted in to Android Auto.
|
||||
*
|
||||
* An app is a car media app when it exports a MediaBrowserService *and* declares the
|
||||
* `com.google.android.gms.car.application` meta-data pointing at a descriptor with
|
||||
* `<uses name="media"/>`. The second half is what separates Deezer from a phone-only player
|
||||
* that merely exposes a browser service to Bluetooth and Wear.
|
||||
* An app is a car media app when it exports a MediaBrowserService *and* its car descriptor (see
|
||||
* [CarDescriptor]) lists `media`. The descriptor is what separates Deezer from a phone-only
|
||||
* player that merely exposes a browser service to Bluetooth and Wear - and from messengers like
|
||||
* Signal and Molly, which declare only `notification` but keep a browser service for voice notes.
|
||||
*/
|
||||
object MediaCatalog {
|
||||
|
||||
private const val CAR_META = "com.google.android.gms.car.application"
|
||||
|
||||
fun installed(context: Context): List<MediaApp> {
|
||||
val manager = context.packageManager
|
||||
return manager.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), PackageManager.GET_META_DATA)
|
||||
.asSequence()
|
||||
.filter { it.serviceInfo.packageName != context.packageName }
|
||||
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
||||
.filter { declaresCar(manager, it.serviceInfo) }
|
||||
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
|
||||
.filter { declaresMedia(manager, it.serviceInfo) }
|
||||
.map {
|
||||
val info = it.serviceInfo
|
||||
MediaApp(
|
||||
@@ -47,11 +48,10 @@ object MediaCatalog {
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun declaresCar(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
|
||||
if (service.metaData?.containsKey(CAR_META) == true) return true
|
||||
return runCatching {
|
||||
manager.getApplicationInfo(service.packageName, PackageManager.GET_META_DATA)
|
||||
.metaData?.containsKey(CAR_META) == true
|
||||
}.getOrDefault(false)
|
||||
/** A descriptor that can't be read keeps the app, rather than dropping a real player over an odd manifest. */
|
||||
private fun declaresMedia(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
|
||||
if (CarDescriptor.meta(manager, service.packageName, service) == null) return false
|
||||
val uses = CarDescriptor.uses(manager, service.packageName, service)
|
||||
return uses.isNullOrEmpty() || "media" in uses
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
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
|
||||
* Notification object stays with the listener, apart from the reply action.
|
||||
@@ -24,6 +27,7 @@ data class CarNotification(
|
||||
val key: String,
|
||||
/** Unique per post, so an update to an already-shown notification still counts as new. */
|
||||
val id: Long,
|
||||
val packageName: String,
|
||||
val appLabel: String,
|
||||
val title: 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. */
|
||||
val reply: ReplyTarget?,
|
||||
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++
|
||||
|
||||
/** [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) {
|
||||
_history.update { list ->
|
||||
(listOf(notification) + list.filterNot { it.key == notification.key }).take(MAX_HISTORY)
|
||||
@@ -76,20 +93,30 @@ object CarNotifications {
|
||||
val echo = notification.key == lastReplyKey &&
|
||||
System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS
|
||||
if (quiet || echo) return
|
||||
// History still keeps it; only the popup is held back while driving.
|
||||
if (CarMotion.moving) return
|
||||
_popup.value = notification
|
||||
val now = android.os.SystemClock.uptimeMillis()
|
||||
_popup.update { shown ->
|
||||
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. */
|
||||
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) {
|
||||
_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. */
|
||||
fun markRead() {
|
||||
_unread.value = 0
|
||||
@@ -102,6 +129,22 @@ object CarNotifications {
|
||||
_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. */
|
||||
fun reply(context: Context, notification: CarNotification, text: String): Boolean {
|
||||
val target = notification.reply ?: return false
|
||||
|
||||
+51
-4
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.notify
|
||||
import android.app.Notification
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
|
||||
/**
|
||||
@@ -29,7 +30,10 @@ class GearslipNotificationListener : NotificationListenerService() {
|
||||
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key)
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -40,22 +44,56 @@ class GearslipNotificationListener : NotificationListenerService() {
|
||||
val text = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
||||
?: extras.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
|
||||
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(
|
||||
CarNotification(
|
||||
key = sbn.key,
|
||||
id = CarNotifications.nextId(),
|
||||
packageName = sbn.packageName,
|
||||
appLabel = appLabel(sbn.packageName),
|
||||
title = title,
|
||||
text = text,
|
||||
title = style?.conversationTitle?.toString()?.takeIf { it.isNotBlank() } ?: title,
|
||||
text = lines.lastOrNull()?.text ?: text,
|
||||
postedAt = sbn.postTime,
|
||||
icon = icon(sbn),
|
||||
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 {
|
||||
packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString()
|
||||
}.getOrDefault(packageName.substringAfterLast('.'))
|
||||
@@ -79,6 +117,15 @@ class GearslipNotificationListener : NotificationListenerService() {
|
||||
|
||||
private companion object {
|
||||
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(
|
||||
Notification.CATEGORY_TRANSPORT,
|
||||
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()
|
||||
}
|
||||
}
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user