mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a7ae1930 | ||
|
|
c52a6c32b7 | ||
|
|
f21df41506 | ||
|
|
ff8a6a4cff | ||
|
|
a2d9ed7008 | ||
|
|
3994c9bd2e | ||
|
|
4e4a246a51 | ||
|
|
ebd0ba4add | ||
|
|
42785ce6be | ||
|
|
db205919c8 | ||
|
|
f452099926 | ||
|
|
03b74b1150 | ||
|
|
fbaeb0cc57 |
No files matched your search
@@ -0,0 +1,59 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in Gearslip broke or behaves wrong.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for reporting. Check the [open issues](https://github.com/Seb3thehacker/gearslip/issues) first, in case someone has already reported it.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: car
|
||||||
|
attributes:
|
||||||
|
label: Car
|
||||||
|
description: Make, model, and year.
|
||||||
|
placeholder: 2019 Honda Civic
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: head-unit
|
||||||
|
attributes:
|
||||||
|
label: Head unit
|
||||||
|
options:
|
||||||
|
- The one the car came with
|
||||||
|
- A factory unit from another car
|
||||||
|
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||||
|
- A homemade or open-source unit, such as LIVI
|
||||||
|
- I don't know
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: connection
|
||||||
|
attributes:
|
||||||
|
label: Connection
|
||||||
|
options:
|
||||||
|
- USB cable
|
||||||
|
- Wireless dongle
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes:
|
||||||
|
label: Log
|
||||||
|
description: |
|
||||||
|
On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||||
|
The log records your phone, its Android version, the Gearslip version, and why the connection ended.
|
||||||
|
placeholder: Drop the log file here.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Car report
|
||||||
|
description: Tell us whether Gearslip works in your car, so others know before they install.
|
||||||
|
labels: ["car report"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Reports from working cars help as much as reports from broken ones.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: car
|
||||||
|
attributes:
|
||||||
|
label: Car
|
||||||
|
description: Make, model, and year.
|
||||||
|
placeholder: 2019 Honda Civic
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: head-unit
|
||||||
|
attributes:
|
||||||
|
label: Head unit
|
||||||
|
options:
|
||||||
|
- The one the car came with
|
||||||
|
- A factory unit from another car
|
||||||
|
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||||
|
- A homemade or open-source unit, such as LIVI
|
||||||
|
- I don't know
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: connection
|
||||||
|
attributes:
|
||||||
|
label: Connection
|
||||||
|
options:
|
||||||
|
- USB cable
|
||||||
|
- Wireless dongle
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: result
|
||||||
|
attributes:
|
||||||
|
label: Does it work?
|
||||||
|
options:
|
||||||
|
- Yes, everything I tried works
|
||||||
|
- Partly. It connects, but something is wrong
|
||||||
|
- No, it never connects
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: notes
|
||||||
|
attributes:
|
||||||
|
label: Notes
|
||||||
|
description: What you tried, and anything that didn't work.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: log
|
||||||
|
attributes:
|
||||||
|
label: Log
|
||||||
|
description: |
|
||||||
|
Needed if the car doesn't work. On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||||
|
placeholder: Drop the log file here.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest something Gearslip should do.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What do you want to do?
|
||||||
|
description: Describe the problem, not the solution. "I can't see my next turn while music plays" helps more than "add a turn widget."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: How should it work?
|
||||||
|
description: Your idea for a fix, if you have one.
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: apps
|
||||||
|
attributes:
|
||||||
|
label: Apps involved
|
||||||
|
description: Any apps this touches, such as a map or music app.
|
||||||
+18
-16
@@ -6,25 +6,27 @@
|
|||||||
/build
|
/build
|
||||||
local.properties
|
local.properties
|
||||||
|
|
||||||
# Reference checkouts only - never referenced by the build; safe to delete entirely.
|
# Keys
|
||||||
/cloned-repos
|
|
||||||
|
|
||||||
# The head unit certificate. Never committed or shared beyond who Seb sends it to directly.
|
|
||||||
/cert
|
|
||||||
|
|
||||||
# The release signing key and its passwords. Losing these breaks app updates; leaking them
|
|
||||||
# lets anyone sign an APK as Gearslip.
|
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
keystore.properties
|
keystore.properties
|
||||||
|
|
||||||
/docs/cert-sweep.sh
|
|
||||||
archive/
|
|
||||||
CLAUDE.md
|
|
||||||
*.pdf
|
|
||||||
extraction/
|
|
||||||
.claude/
|
|
||||||
todo
|
|
||||||
logs/
|
|
||||||
# Native build cache.
|
# Native build cache.
|
||||||
.cxx/
|
.cxx/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
/docs/cert-sweep.sh
|
||||||
|
extraction/
|
||||||
|
/cloned-repos
|
||||||
|
|
||||||
|
# Internal
|
||||||
|
*.pdf
|
||||||
|
todo
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# For AI code
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Other
|
||||||
|
archive/
|
||||||
@@ -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.
|
||||||
@@ -25,8 +25,13 @@ mode.
|
|||||||
doesn't ship a car interface of its own.
|
doesn't ship a car interface of its own.
|
||||||
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
||||||
the current and coming weather.
|
the current and coming weather.
|
||||||
- **Notifications.** Read and reply to notifications from the car screen, using the same
|
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
|
||||||
on-screen keyboard.
|
each one aloud, and you reply by voice or with a quick reply. Speech runs on the phone,
|
||||||
|
so it needs no Google account.
|
||||||
|
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||||
|
The rest wait on the dashboard.
|
||||||
|
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||||
|
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -38,16 +43,23 @@ mode.
|
|||||||
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
||||||
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
Library defines, and it draws its own keyboard for search, sign-in, and text fields.
|
||||||
It has been tested against several real navigation and media apps.
|
It has been tested against several real navigation and media apps.
|
||||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** LIVI, used in open source car dashboards, is fully supported by Gearslip.
|
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||||
|
used in homemade car dashboards.
|
||||||
|
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
||||||
|
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||||
|
Gearslip's own player.
|
||||||
|
- **For now, Gearslip works with cars from before 2020.** Newer head units reject its
|
||||||
|
certificate.
|
||||||
|
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||||
|
car to hear calls through its speakers.
|
||||||
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
|
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
|
||||||
requires a screen-share style consent dialog and indicator for this, even though
|
shows a screen-share consent dialog for this, even though Gearslip never reads the
|
||||||
Gearslip never reads the screen; that grant is the only way a non-privileged app can
|
screen; it is the only way an ordinary app can capture another app's audio. The dialog
|
||||||
tap another app's audio. The consent normally repeats every drive; two optional adb
|
appears once per drive.
|
||||||
commands in the setup guide reduce it to a one-time grant.
|
|
||||||
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||||
Android permission or an on-device consent dialog. The two adb commands mentioned
|
Android permission or an on-device consent dialog. The setup guide offers two optional
|
||||||
above, for audio, are the sole exception: both are optional, and both only remove a
|
adb commands: one removes the audio dialog for good, and the other keeps notifications
|
||||||
dialog that the driver would otherwise see once per drive.
|
visible while audio plays.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
@@ -67,6 +79,12 @@ keystore. Without them, the debug build still works.
|
|||||||
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
|
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
|
||||||
can render: templated apps, media apps, and the Gearslip app protocol.
|
can render: templated apps, media apps, and the Gearslip app protocol.
|
||||||
|
|
||||||
|
## Trademarks
|
||||||
|
|
||||||
|
Android, Android Auto, Google Messages, and Waze are trademarks of Google LLC. Spotify is
|
||||||
|
a trademark of Spotify AB. Gearslip is not affiliated with, endorsed by, or sponsored by
|
||||||
|
any of these companies. All other names belong to their owners.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
|
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# Gearslip 0.1.0-alpha
|
|
||||||
|
|
||||||
This release lets you read and answer messages by voice, and the speech runs on your phone with no Google services.
|
|
||||||
|
|
||||||
## Messages and voice
|
|
||||||
|
|
||||||
- Messaging apps such as Signal and Molly open to your recent chats.
|
|
||||||
- Gearslip reads a message aloud, and you reply by voice. Say "cancel" or "change it" before it sends, or tap a quick reply such as your arrival time.
|
|
||||||
- Voice works on any phone, GrapheneOS included. Gearslip turns speech into text itself, so it needs no Google account.
|
|
||||||
- New messages show as a small card at the top right, even while you drive. A bar shows how long until the card closes or the reply sends.
|
|
||||||
- Only messages, missed calls, alarms, and reminders pop up. Everything else waits on the dashboard.
|
|
||||||
|
|
||||||
## Map
|
|
||||||
|
|
||||||
- The map stays loaded when you switch screens, so it no longer flashes black.
|
|
||||||
- Map apps draw more reliably and keep their connection when the phone is locked.
|
|
||||||
- The Home button shows your map app, so it no longer appears twice.
|
|
||||||
- The next turn shows in the nav bar when you leave the map.
|
|
||||||
|
|
||||||
## Controls and settings
|
|
||||||
|
|
||||||
- Steering wheel buttons, control knobs, and D-pads now work.
|
|
||||||
- To see the whole song title and artist in the nav bar, choose Settings > Player in the nav bar > Song title.
|
|
||||||
- Choose miles or kilometres under Settings > Units.
|
|
||||||
- "Button style" is now called "Theme".
|
|
||||||
- Try the voice assistant and the vehicle data screen under Settings > Experimental features.
|
|
||||||
- Logs now say why a car ended the connection.
|
|
||||||
|
|
||||||
## Known issues
|
|
||||||
|
|
||||||
- Spotify, Waze, and Google Messages updated to a version of Google's car library that only accepts Android Auto itself. Their own car screens won't open in Gearslip. Spotify still plays through the Gearslip player, which has the green check.
|
|
||||||
- Calls go through the car's Bluetooth, as they do in Android Auto. Pair your phone with the car to hear calls on its speakers.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Download `Gearslip-release.apk` below and open it on your phone. It updates 0.0.05-alpha in place and keeps your settings.
|
|
||||||
|
|
||||||
Found a bug, or a car where the buttons don't work? [Open an issue](https://github.com/Seb3thehacker/gearslip/issues) and share the log from the Logs screen in the phone app.
|
|
||||||
+65
-9
@@ -1,11 +1,12 @@
|
|||||||
# Building apps for Gearslip
|
# Building apps for Gearslip
|
||||||
|
|
||||||
Gearslip renders three kinds of app on a car screen. This page specifies all three.
|
Gearslip renders four kinds of app on a car screen. This page specifies all four.
|
||||||
|
|
||||||
| Kind | What it is | Status |
|
| Kind | What it is | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [Templated apps](#templated-apps) | An AndroidX Car App Library app. Gearslip hosts it. | Works today |
|
| [Templated apps](#templated-apps) | An AndroidX Car App Library app. Gearslip hosts it. | Works today |
|
||||||
| [Media apps](#media-apps) | A `MediaBrowserService` that opted in to Android Auto. | Works today |
|
| [Media apps](#media-apps) | A `MediaBrowserService` that opted in to Android Auto. | Works today |
|
||||||
|
| [Messaging apps](#messaging-apps) | An app whose notifications opted in to Android Auto. | Works today |
|
||||||
| [Gearslip apps](#gearslip-apps) | A native Gearslip screen, described over binder. | Specified here; not yet implemented |
|
| [Gearslip apps](#gearslip-apps) | A native Gearslip screen, described over binder. | Specified here; not yet implemented |
|
||||||
|
|
||||||
Write a templated app when your app fits the Car App Library's templates, because it runs today
|
Write a templated app when your app fits the Car App Library's templates, because it runs today
|
||||||
@@ -29,11 +30,11 @@ any other host.
|
|||||||
Your app decides which hosts may render it, through the `HostValidator` your `CarAppService`
|
Your app decides which hosts may render it, through the `HostValidator` your `CarAppService`
|
||||||
returns.
|
returns.
|
||||||
|
|
||||||
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`, and the library's own
|
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`. Car App Library 1.8.0-beta01
|
||||||
`HostValidator` accepts any host holding it. If you build your validator with
|
and earlier, and 1.9.0-alpha01, accept any host holding it, so Gearslip passes the default
|
||||||
`HostValidator.Builder` and no allowlist, Gearslip already passes, and you need nothing below.
|
validator. From 1.8.0-rc01 and 1.9.0-alpha02 the library checks that permission only on
|
||||||
Add an allowlist entry when you wrote a custom validator, or when you want to name Gearslip
|
Android Automotive, so on a phone Gearslip passes only when your allowlist names it. Add this
|
||||||
explicitly:
|
entry:
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<!-- res/values/arrays.xml -->
|
<!-- res/values/arrays.xml -->
|
||||||
@@ -143,7 +144,14 @@ existing video pipe. Gearslip never reads those pixels back.
|
|||||||
|
|
||||||
Gearslip then reports which part of that surface the driver can see. Treat the visible area as
|
Gearslip then reports which part of that surface the driver can see. Treat the visible area as
|
||||||
the region safe for your own controls, and the stable area as the region that never gets
|
the region safe for your own controls, and the stable area as the region that never gets
|
||||||
covered. Gearslip keeps its chrome to a top bar, so both areas are the surface minus that bar.
|
covered. Gearslip reports the same area for both, and it depends on the template:
|
||||||
|
|
||||||
|
| Template | What Gearslip covers |
|
||||||
|
| --- | --- |
|
||||||
|
| `NavigationTemplate` | A strip along the bottom for the trip estimate |
|
||||||
|
| `MapWithContentTemplate`, `MapTemplate`, and the place list and route preview templates | A content pane on the left |
|
||||||
|
|
||||||
|
The turn card and map buttons float over the visible area, as they do in Android Auto.
|
||||||
|
|
||||||
Your app receives four gestures, never raw touches: `onClick`, `onScroll`, `onFling` and
|
Your app receives four gestures, never raw touches: `onClick`, `onScroll`, `onFling` and
|
||||||
`onScale`. Coordinates are surface pixels. Scroll distances follow `GestureDetector`'s
|
`onScale`. Coordinates are surface pixels. Scroll distances follow `GestureDetector`'s
|
||||||
@@ -197,6 +205,53 @@ can start it playing on connection.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Messaging apps
|
||||||
|
|
||||||
|
Gearslip shows conversations from the notifications your app already posts, as Android Auto
|
||||||
|
does. Your app needs no car screen of its own.
|
||||||
|
|
||||||
|
Gearslip lists a messaging app when its car descriptor declares notifications:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- res/xml/automotive_app_desc.xml -->
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="notification" />
|
||||||
|
</automotiveApp>
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference it from the same `com.google.android.gms.car.application` meta-data that a media app
|
||||||
|
uses. Opening your tile on the car screen shows your recent conversations.
|
||||||
|
|
||||||
|
### Posting messages
|
||||||
|
|
||||||
|
Post each conversation as a `MessagingStyle` notification. Gearslip reads the conversation
|
||||||
|
title, each message's sender and text, and whether the chat is a group, and shows the last eight
|
||||||
|
messages. Leave the sender empty on the phone owner's own messages; Gearslip treats a message
|
||||||
|
with no sender as one the driver sent.
|
||||||
|
|
||||||
|
Gearslip reads new messages aloud and lets the driver reply by voice or with a quick reply. For
|
||||||
|
that, attach two actions, either to the notification or to its `WearableExtender`:
|
||||||
|
|
||||||
|
- **Reply:** an action carrying a `RemoteInput` that allows free-form input. Gearslip fills it
|
||||||
|
with the driver's reply and fires the action's intent.
|
||||||
|
- **Mark as read:** an action with `SEMANTIC_ACTION_MARK_AS_READ`. Gearslip shows a Mark read
|
||||||
|
button when you provide it.
|
||||||
|
|
||||||
|
### Which notifications interrupt the driver
|
||||||
|
|
||||||
|
Gearslip pops up a card for a conversation, a notification with a reply action, or a
|
||||||
|
notification in the `msg`, `missed_call`, `alarm`, `reminder` or `event` category. A channel
|
||||||
|
below default importance never pops up. Every other notification waits quietly on the
|
||||||
|
dashboard.
|
||||||
|
|
||||||
|
Gearslip drops these altogether:
|
||||||
|
|
||||||
|
- ongoing notifications and group summaries;
|
||||||
|
- the `transport`, `progress`, `service`, `sys` and `navigation` categories; and
|
||||||
|
- media notifications, because the player already shows them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Gearslip apps
|
## Gearslip apps
|
||||||
|
|
||||||
This section specifies protocol version 1. Gearslip does not yet implement it.
|
This section specifies protocol version 1. Gearslip does not yet implement it.
|
||||||
@@ -376,7 +431,7 @@ long strings. Send a screen that fits, rather than one Gearslip has to cut.
|
|||||||
"densityDpi": 160,
|
"densityDpi": 160,
|
||||||
"batteryPercent": 82,
|
"batteryPercent": 82,
|
||||||
"charging": true,
|
"charging": true,
|
||||||
"vehicle": "Uconnect"
|
"vehicle": "Honda"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -416,6 +471,7 @@ home-screen pane comes later. Anything that needs its own rendering belongs in a
|
|||||||
|
|
||||||
## Further reading
|
## Further reading
|
||||||
|
|
||||||
- [README.md](../README.md) - what Gearslip is and how to build it.
|
- [README.md](../README.md) - what Gearslip is.
|
||||||
|
- [CONTRIBUTING.md](../CONTRIBUTING.md) - how to build Gearslip and report bugs.
|
||||||
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
|
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
|
||||||
reference for templated apps.
|
reference for templated apps.
|
||||||
@@ -32,8 +32,8 @@ android {
|
|||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 6
|
versionCode = 7
|
||||||
versionName = "0.1.0-alpha"
|
versionName = "0.1.10-alpha"
|
||||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ object Changelog {
|
|||||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||||
|
|
||||||
val releases = listOf(
|
val releases = listOf(
|
||||||
|
Release(
|
||||||
|
7, "0.1.10-alpha",
|
||||||
|
listOf(
|
||||||
|
"The launcher groups your apps: Phone, Maps, Music, Messaging, Web, Screen sharing, and Settings.",
|
||||||
|
"To move an app, press and hold it on the launcher, then tap Move left or Move right.",
|
||||||
|
"MapQuest and Flitsmeister work in Gearslip and show the green check.",
|
||||||
|
"Spotify has one tile. If Spotify isn't open on the phone, tap Open Spotify to start it.",
|
||||||
|
"Flick a map and it glides on, as it does in Android Auto.",
|
||||||
|
"The map picks up where it left off when you come back from another screen.",
|
||||||
|
"Search boxes in map apps are larger and easier to read.",
|
||||||
|
"Map apps keep the right icons on their buttons after they update.",
|
||||||
|
"When Gearslip asks \"Do you want to reply?\", a bar shows how long it will listen. The bar stops while you speak.",
|
||||||
|
"Gearslip fits its picture to wide car screens and keeps it clear of the edges the car crops.",
|
||||||
|
"Gearslip answers the car's connection checks correctly. Some cars, Audis among them, hung up after a second without this.",
|
||||||
|
"Google Maps no longer shows up as a messaging app.",
|
||||||
|
),
|
||||||
|
),
|
||||||
Release(
|
Release(
|
||||||
6, "0.1.0-alpha",
|
6, "0.1.0-alpha",
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
@@ -631,15 +631,11 @@ class GearslipRunner(
|
|||||||
"configuration_indices=$indices",
|
"configuration_indices=$indices",
|
||||||
)
|
)
|
||||||
if (maxUnackedFromConfig != null) maxUnacked = maxOf(1, maxUnackedFromConfig)
|
if (maxUnackedFromConfig != null) maxUnacked = maxOf(1, maxUnackedFromConfig)
|
||||||
// Prefer the smallest offered resolution for the first attempt: fewer pixels,
|
|
||||||
// fewer ways for a first encode to go wrong.
|
|
||||||
val offered = indices.map { it.toInt() }
|
val offered = indices.map { it.toInt() }
|
||||||
val preferred = vehicleProfile?.resolution?.let { want ->
|
val preferred = vehicleProfile?.resolution?.let { want ->
|
||||||
offered.firstOrNull { videoConfigs.getOrNull(it)?.resolutionName == want }
|
offered.firstOrNull { videoConfigs.getOrNull(it)?.resolutionName == want }
|
||||||
}
|
}
|
||||||
selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
|
selectedConfigIndex = preferred ?: fitToScreen(offered) ?: offered.firstOrNull()
|
||||||
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
|
|
||||||
} ?: offered.firstOrNull()
|
|
||||||
log.i("selected config index $selectedConfigIndex from offered $offered")
|
log.i("selected config index $selectedConfigIndex from offered $offered")
|
||||||
SessionReport.video(
|
SessionReport.video(
|
||||||
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
||||||
@@ -685,6 +681,20 @@ class GearslipRunner(
|
|||||||
// -> Data (8-byte big-endian timestamp, then the H.264 access unit)
|
// -> Data (8-byte big-endian timestamp, then the H.264 access unit)
|
||||||
// <- Ack { session_id, ack } - at most max_unacked frames may be outstanding
|
// <- Ack { session_id, ack } - at most max_unacked frames may be outstanding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offered config that best fills the car's screen: the smallest whose visible width
|
||||||
|
* reaches most of the touchscreen's, else the largest there is. Always taking the smallest
|
||||||
|
* put 800x480 on a 1540x720 Audi screen and stretched it. With no touchscreen size to go
|
||||||
|
* by, the smallest is still the safe choice.
|
||||||
|
*/
|
||||||
|
private fun fitToScreen(offered: List<Int>): Int? {
|
||||||
|
val known = offered.filter { videoConfigs.getOrNull(it)?.pixelSize() != null }
|
||||||
|
fun pixels(idx: Int) = videoConfigs[idx].pixelCount()
|
||||||
|
if (touchWidth <= 0) return known.minByOrNull(::pixels)
|
||||||
|
val wide = known.filter { (videoConfigs[it].visibleSize()?.first ?: 0) >= touchWidth * 8 / 10 }
|
||||||
|
return wide.minByOrNull(::pixels) ?: known.maxByOrNull(::pixels)
|
||||||
|
}
|
||||||
|
|
||||||
private fun startVideoSource() {
|
private fun startVideoSource() {
|
||||||
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
|
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
|
||||||
val index = selectedConfigIndex ?: 0
|
val index = selectedConfigIndex ?: 0
|
||||||
@@ -702,6 +712,13 @@ class GearslipRunner(
|
|||||||
Protobuf.varintField(2, index.toLong())
|
Protobuf.varintField(2, index.toLong())
|
||||||
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
|
||||||
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
|
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
|
||||||
|
// The car crops its margins off the frame, so keep the UI out of them unless a vehicle
|
||||||
|
// profile has its own tuned insets.
|
||||||
|
if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
|
||||||
|
val insets = config.marginInsets()
|
||||||
|
CarEnvironment.setInsets(insets)
|
||||||
|
if (insets != Insets.NONE) log.i("keeping the UI clear of the car's margins: $insets")
|
||||||
|
}
|
||||||
|
|
||||||
val useProjection = projection != null
|
val useProjection = projection != null
|
||||||
videoSource = VideoSource(
|
videoSource = VideoSource(
|
||||||
@@ -800,9 +817,11 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onPingRequest(body: ByteArray) {
|
private fun onPingRequest(body: ByteArray) {
|
||||||
val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime()
|
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
|
||||||
|
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
|
||||||
|
val timestamp = Protobuf.readInt64Field(body, 1) ?: System.nanoTime()
|
||||||
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
|
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
|
||||||
log.i("<- PingRequest / -> PingResponse (keeping the session alive)")
|
log.i("<- PingRequest(timestamp=$timestamp) / -> PingResponse (keeping the session alive)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun send(
|
private fun send(
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ object Protobuf {
|
|||||||
varint((field shl 3).toLong()) + varint(value)
|
varint((field shl 3).toLong()) + varint(value)
|
||||||
|
|
||||||
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
|
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
|
||||||
fun readInt32Field(data: ByteArray, field: Int): Int? {
|
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
||||||
|
|
||||||
|
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
||||||
|
fun readInt64Field(data: ByteArray, field: Int): Long? {
|
||||||
var pos = 0
|
var pos = 0
|
||||||
while (pos < data.size) {
|
while (pos < data.size) {
|
||||||
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
||||||
@@ -45,7 +48,7 @@ object Protobuf {
|
|||||||
0 -> {
|
0 -> {
|
||||||
val (value, afterValue) = readVarint(data, pos) ?: return null
|
val (value, afterValue) = readVarint(data, pos) ?: return null
|
||||||
pos = afterValue
|
pos = afterValue
|
||||||
if (number == field) return value.toInt()
|
if (number == field) return value
|
||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
val (len, afterLen) = readVarint(data, pos) ?: return null
|
val (len, afterLen) = readVarint(data, pos) ?: return null
|
||||||
|
|||||||
@@ -45,7 +45,19 @@ class ScreenProjector(private val context: Context) {
|
|||||||
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
||||||
private var downTime = 0L
|
private var downTime = 0L
|
||||||
|
|
||||||
fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) {
|
/**
|
||||||
|
* [name] names the virtual display so the two callers are told apart from outside the app
|
||||||
|
* (`dumpsys display`): the real car session keeps "gearslip-projection", the on-phone bench
|
||||||
|
* preview passes "gearslip-preview".
|
||||||
|
*/
|
||||||
|
fun start(
|
||||||
|
surface: Surface,
|
||||||
|
width: Int,
|
||||||
|
height: Int,
|
||||||
|
densityDpi: Int,
|
||||||
|
name: String = "gearslip-projection",
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
val displayManager = context.getSystemService(DisplayManager::class.java)
|
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||||
@@ -57,7 +69,7 @@ class ScreenProjector(private val context: Context) {
|
|||||||
// driver on every connect.
|
// driver on every connect.
|
||||||
val display = try {
|
val display = try {
|
||||||
displayManager.createVirtualDisplay(
|
displayManager.createVirtualDisplay(
|
||||||
"gearslip-projection", width, height, densityDpi, surface,
|
name, width, height, densityDpi, surface,
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
||||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ object ServiceDiscovery {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
|
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
|
||||||
* and 4): pixels of the frame it does not treat as usable UI. Logged, and available to a
|
* and 4): pixels of the frame it crops away, split evenly between the two edges, to fit the
|
||||||
* vehicle profile, but not applied automatically - what they mean varies by unit.
|
* frame to a screen of a different shape. A matched vehicle profile's insets override them.
|
||||||
*/
|
*/
|
||||||
class VideoConfig(
|
class VideoConfig(
|
||||||
val resolution: Int,
|
val resolution: Int,
|
||||||
@@ -38,6 +38,15 @@ object ServiceDiscovery {
|
|||||||
|
|
||||||
fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE
|
fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE
|
||||||
|
|
||||||
|
/** What's left on the car's screen once the margins are cropped off. */
|
||||||
|
fun visibleSize(): Pair<Int, Int>? = pixelSize()?.let { (w, h) -> (w - widthMargin) to (h - heightMargin) }
|
||||||
|
|
||||||
|
/** The margins as the strips of the frame the car UI keeps clear. */
|
||||||
|
fun marginInsets(): Insets = Insets(
|
||||||
|
top = heightMargin / 2, bottom = heightMargin - heightMargin / 2,
|
||||||
|
left = widthMargin / 2, right = widthMargin - widthMargin / 2,
|
||||||
|
)
|
||||||
|
|
||||||
override fun toString() =
|
override fun toString() =
|
||||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import android.view.SurfaceHolder
|
|||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
|
import kotlin.math.hypot
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -125,6 +130,30 @@ private fun SurfaceHost(
|
|||||||
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
|
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
|
||||||
if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom)
|
if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(connection) {
|
||||||
|
// detectTransformGestures has no velocity, so a fling is measured here alongside
|
||||||
|
// it: one finger lifting while still moving lets the map glide on, as on a car.
|
||||||
|
// Velocity is the finger's own, which is GestureDetector's onFling convention.
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
val tracker = VelocityTracker()
|
||||||
|
tracker.addPosition(down.uptimeMillis, down.position)
|
||||||
|
var pinched = false
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent(PointerEventPass.Final)
|
||||||
|
if (event.changes.size > 1) pinched = true
|
||||||
|
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||||
|
tracker.addPosition(change.uptimeMillis, change.position)
|
||||||
|
if (!change.pressed) {
|
||||||
|
val v = tracker.calculateVelocity()
|
||||||
|
if (!pinched && hypot(v.x, v.y) >= FLING_MIN_PX_S) {
|
||||||
|
connection.surfaceFling(v.x, v.y)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
SurfaceView(context).apply {
|
SurfaceView(context).apply {
|
||||||
@@ -188,3 +217,6 @@ private fun ChromeBar(
|
|||||||
|
|
||||||
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
||||||
private val CHROME_BAR = 44.dp
|
private val CHROME_BAR = 44.dp
|
||||||
|
|
||||||
|
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
|
||||||
|
private const val FLING_MIN_PX_S = 400f
|
||||||
@@ -83,12 +83,13 @@ fun CarHome() {
|
|||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
val panel = navigator.sidePanel
|
val panel = navigator.sidePanel
|
||||||
|
|
||||||
// Coming back to the map from another screen: make sure the nav app is drawing into a live
|
// Coming back to the map from another screen: the map stayed loaded underneath, so its surface
|
||||||
// surface. The first composition is skipped, since the surface is only just being lent.
|
// is only replaced if it died meanwhile. The first composition is skipped, since the surface
|
||||||
|
// is only just being lent.
|
||||||
val onHome = navigator.current == CarScreen.Home
|
val onHome = navigator.current == CarScreen.Home
|
||||||
val seenHome = remember { booleanArrayOf(false) }
|
val seenHome = remember { booleanArrayOf(false) }
|
||||||
LaunchedEffect(onHome) {
|
LaunchedEffect(onHome) {
|
||||||
if (onHome && seenHome[0]) CarServices.nav.refreshSurface()
|
if (onHome && seenHome[0]) CarServices.nav.replaceDeadSurface()
|
||||||
if (onHome) seenHome[0] = true
|
if (onHome) seenHome[0] = true
|
||||||
}
|
}
|
||||||
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||||
|
|||||||
@@ -133,10 +133,16 @@ internal object LauncherCache {
|
|||||||
// own Messages screen does the same job, so a messenger gets only that tile.
|
// own Messages screen does the same job, so a messenger gets only that tile.
|
||||||
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
val messagingPackages = messagingApps.map { it.packageName }.toSet()
|
||||||
|
|
||||||
val nav = navApps.filter { it.component.packageName !in messagingPackages }.map {
|
val nav = navApps
|
||||||
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
|
.filter { it.component.packageName !in messagingPackages }
|
||||||
Entry(label, iconOf(it.component.packageName), template = it)
|
// 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) }
|
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.
|
// A messenger that is also a car media app keeps only its player tile.
|
||||||
val messaging = messagingApps
|
val messaging = messagingApps
|
||||||
@@ -157,6 +163,50 @@ internal fun rememberAllEntries(): List<Entry> {
|
|||||||
return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
|
return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The launcher's groups, in the order they're laid out. */
|
||||||
|
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS }
|
||||||
|
|
||||||
|
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||||
|
entry.builtIn?.let {
|
||||||
|
return when (it) {
|
||||||
|
BuiltInApps.Phone -> AppGroup.PHONE
|
||||||
|
BuiltInApps.Web -> AppGroup.WEB
|
||||||
|
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||||
|
else -> AppGroup.SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.media != null) return AppGroup.MUSIC
|
||||||
|
if (entry.messaging != null) return AppGroup.MESSAGING
|
||||||
|
val app = entry.template ?: return AppGroup.OTHER
|
||||||
|
// A media app's "· Browse" tile sits next to its player.
|
||||||
|
if (app.component.packageName in mediaPackages) return AppGroup.MUSIC
|
||||||
|
return when (app.kind) {
|
||||||
|
"calling" -> AppGroup.PHONE
|
||||||
|
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
|
||||||
|
"messaging" -> AppGroup.MESSAGING
|
||||||
|
"settings" -> AppGroup.SETTINGS
|
||||||
|
else -> AppGroup.OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
|
||||||
|
* group with Settings last of all, then with the driver's own moves from [custom] laid over it.
|
||||||
|
* Moved apps only trade places among themselves, so an app installed later still lands in its group.
|
||||||
|
*/
|
||||||
|
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> {
|
||||||
|
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
|
||||||
|
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
|
||||||
|
val grouped = shown.sortedWith(
|
||||||
|
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
|
||||||
|
)
|
||||||
|
if (custom.isEmpty()) return grouped
|
||||||
|
val rank = custom.withIndex().associate { (i, id) -> id to i }
|
||||||
|
fun Entry.rank() = componentId?.let { rank[it] }
|
||||||
|
val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
|
||||||
|
return grouped.map { if (it.rank() != null) moved.next() else it }
|
||||||
|
}
|
||||||
|
|
||||||
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
||||||
internal fun findEntry(id: String): Entry? =
|
internal fun findEntry(id: String): Entry? =
|
||||||
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
|
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
|
||||||
@@ -184,7 +234,7 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro
|
|||||||
entry.messaging?.let { navigator.messages(it.packageName, it.label) }
|
entry.messaging?.let { navigator.messages(it.packageName, it.label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */
|
||||||
@Composable
|
@Composable
|
||||||
fun CarLauncher() {
|
fun CarLauncher() {
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
@@ -210,11 +260,8 @@ fun CarLauncher() {
|
|||||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gearslip's own apps first, then everything installed, then Settings last where it's expected.
|
val appOrder by CarSettings.appOrder.collectAsState()
|
||||||
val builtIns = all.filter { it.builtIn != null && (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) }
|
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
|
||||||
val tiles = builtIns.filter { it.builtIn !== BuiltInApps.Settings }.map(::tileOf) +
|
|
||||||
all.filter { it.builtIn == null }.map(::tileOf) +
|
|
||||||
builtIns.filter { it.builtIn === BuiltInApps.Settings }.map(::tileOf)
|
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ object CarSettings {
|
|||||||
private const val KEY_THEME = "car_app_theme"
|
private const val KEY_THEME = "car_app_theme"
|
||||||
private const val KEY_UI_THEME = "car_ui_theme"
|
private const val KEY_UI_THEME = "car_ui_theme"
|
||||||
private const val KEY_PINNED = "car_pinned_apps"
|
private const val KEY_PINNED = "car_pinned_apps"
|
||||||
|
private const val KEY_APP_ORDER = "car_app_order"
|
||||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||||
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
private const val KEY_VOICE_ASSISTANT = "car_voice_assistant"
|
||||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||||
@@ -66,6 +67,7 @@ object CarSettings {
|
|||||||
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
||||||
val uiTheme: StateFlow<String> = uiThemeFlow
|
val uiTheme: StateFlow<String> = uiThemeFlow
|
||||||
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
||||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||||
private val voiceAssistantFlow = MutableStateFlow(false)
|
private val voiceAssistantFlow = MutableStateFlow(false)
|
||||||
private val carSensorsFlow = MutableStateFlow(false)
|
private val carSensorsFlow = MutableStateFlow(false)
|
||||||
@@ -120,6 +122,9 @@ object CarSettings {
|
|||||||
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
||||||
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
||||||
|
|
||||||
|
/** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
|
||||||
|
val appOrder: StateFlow<List<String>> = appOrderFlow
|
||||||
|
|
||||||
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||||
val lastNav: StateFlow<String?> = lastNavFlow
|
val lastNav: StateFlow<String?> = lastNavFlow
|
||||||
val lastMedia: StateFlow<String?> = lastMediaFlow
|
val lastMedia: StateFlow<String?> = lastMediaFlow
|
||||||
@@ -197,6 +202,8 @@ object CarSettings {
|
|||||||
}.getOrDefault(NightMode.AUTO)
|
}.getOrDefault(NightMode.AUTO)
|
||||||
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
||||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||||
|
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
|
||||||
|
.split(",").filter { it.isNotEmpty() }
|
||||||
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||||
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
voiceAssistantFlow.value = AppSettings.getString(app, KEY_VOICE_ASSISTANT, "false") == "true"
|
||||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||||
@@ -286,4 +293,9 @@ object CarSettings {
|
|||||||
pinnedFlow.value = next
|
pinnedFlow.value = next
|
||||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAppOrder(ids: List<String>) {
|
||||||
|
appOrderFlow.value = ids
|
||||||
|
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +111,12 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
|
||||||
|
"${app.label} isn't running.",
|
||||||
|
"${app.label} plays through Gearslip's own player, but only while it's " +
|
||||||
|
"open on the phone. Open it once and it keeps playing here in the car.",
|
||||||
|
action = "Open ${app.label}" to { media.launchAndConnect(app) },
|
||||||
|
)
|
||||||
CarMedia.Rejection.REFUSED -> Notice(
|
CarMedia.Rejection.REFUSED -> Notice(
|
||||||
"${app.label} would not let Gearslip browse it.",
|
"${app.label} would not let Gearslip browse it.",
|
||||||
"Some media apps only accept Google's own host.",
|
"Some media apps only accept Google's own host.",
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ internal fun closeApp(slot: RunningSlot, navigator: CarNavigator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a long press on an app offers - pin it to or unpin it from the nav bar, and close it if
|
* What a long press on an app offers - pin it to or unpin it from the nav bar, move it along the
|
||||||
* it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that
|
* launcher, and close it if it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that
|
||||||
* dismisses it. Drawn in the car UI's own layout rather than a system popup window, which the
|
* dismisses it. Drawn in the car UI's own layout rather than a system popup window, which the
|
||||||
* car's virtual display has no reason to host well.
|
* car's virtual display has no reason to host well.
|
||||||
*/
|
*/
|
||||||
@@ -218,6 +218,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (navigator.current == CarScreen.Apps) MoveButtons(id)
|
||||||
if (slot != null) {
|
if (slot != null) {
|
||||||
GsButton(
|
GsButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -237,3 +238,41 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the app one place left or right on the launcher. The menu stays open so a tile can be
|
||||||
|
* walked several places, and the grid behind the scrim shows where it went.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun MoveButtons(id: String) {
|
||||||
|
val custom by CarSettings.appOrder.collectAsState()
|
||||||
|
val order = launcherOrder(rememberAllEntries(), rememberVehicleDataShown(), custom).mapNotNull { it.componentId }
|
||||||
|
val index = order.indexOf(id)
|
||||||
|
if (index < 0) return
|
||||||
|
fun move(by: Int) {
|
||||||
|
val next = order.toMutableList()
|
||||||
|
java.util.Collections.swap(next, index, index + by)
|
||||||
|
CarSettings.setAppOrder(next)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
GsButton(
|
||||||
|
onClick = { move(-1) },
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
enabled = index > 0,
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
) { Text("Move left", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
GsButton(
|
||||||
|
onClick = { move(1) },
|
||||||
|
tone = GsTone.Tonal,
|
||||||
|
enabled = index < order.lastIndex,
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
) { Text("Move right", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
}
|
||||||
|
if (custom.isNotEmpty()) {
|
||||||
|
GsButton(
|
||||||
|
onClick = { CarSettings.setAppOrder(emptyList()) },
|
||||||
|
tone = GsTone.Neutral,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) { Text("Reset app order", style = MaterialTheme.typography.titleMedium) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package app.seb3thehacker.gearslip.car
|
|||||||
|
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -246,7 +248,15 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
template.headerAction?.let { ActionButton(it) }
|
template.headerAction?.let { ActionButton(it) }
|
||||||
SearchField(query, template.searchHint.orEmpty(), Modifier.weight(1f))
|
SearchField(
|
||||||
|
query,
|
||||||
|
template.searchHint.orEmpty(),
|
||||||
|
Modifier.weight(1f),
|
||||||
|
onClear = {
|
||||||
|
query = ""
|
||||||
|
delegate.textChanged("")
|
||||||
|
},
|
||||||
|
)
|
||||||
ActionRow(template.actionStrip?.actions.orEmpty())
|
ActionRow(template.actionStrip?.actions.orEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,22 +276,59 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The field the on-screen keyboard types into. It is the same height and shape as the round
|
||||||
|
* buttons beside it, so the header reads as one row of controls rather than a thin strip squeezed
|
||||||
|
* between two big buttons, and it carries a magnifier so it reads as search at a glance.
|
||||||
|
* [onClear] adds a clear button once something is typed; [searchIcon] is off for sign-in fields.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchField(query: String, hint: String, modifier: Modifier = Modifier) {
|
private fun SearchField(
|
||||||
|
query: String,
|
||||||
|
hint: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
searchIcon: Boolean = true,
|
||||||
|
onClear: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth().height(ICON_BUTTON_SIZE),
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
Modifier.padding(start = if (searchIcon) 16.dp else 22.dp, end = 6.dp),
|
||||||
style = ChromeType.body,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
) {
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
if (searchIcon) {
|
||||||
maxLines = 1,
|
androidx.compose.material3.Icon(
|
||||||
overflow = TextOverflow.Ellipsis,
|
androidx.compose.material.icons.Icons.Filled.Search,
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +391,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
|
|||||||
val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD
|
val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD
|
||||||
|
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text())
|
SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text(), searchIcon = false)
|
||||||
val error = method.errorMessage.text()
|
val error = method.errorMessage.text()
|
||||||
if (error.isNotEmpty()) {
|
if (error.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -680,5 +680,5 @@ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
|
|||||||
return seen > 0
|
return seen > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private val ICON_BUTTON_SIZE = 54.dp
|
internal val ICON_BUTTON_SIZE = 54.dp
|
||||||
private val ICON_BUTTON_GLYPH = 30.dp
|
private val ICON_BUTTON_GLYPH = 30.dp
|
||||||
@@ -92,24 +92,37 @@ object VoiceReply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Do you want to reply?" A yes goes on to the reply; anything else ends quietly. */
|
/**
|
||||||
|
* "Do you want to reply?" A yes goes on to the reply; anything else ends quietly. The bar runs
|
||||||
|
* while it listens, so the card never looks stuck, and holds once the driver starts talking.
|
||||||
|
*/
|
||||||
private fun offer(id: Int) {
|
private fun offer(id: Int) {
|
||||||
_state.value = _state.value.copy(phase = Phase.OFFERING, heard = "")
|
_state.value = _state.value.copy(phase = Phase.OFFERING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||||
CarAssistant.say("Do you want to reply?") {
|
CarAssistant.say("Do you want to reply?") {
|
||||||
if (id != run) return@say
|
if (id != run) return@say
|
||||||
|
val until = SystemClock.uptimeMillis() + OFFER_MS
|
||||||
|
_state.value = _state.value.copy(until = until, span = OFFER_MS)
|
||||||
|
// The recognizer gives up on its own; this only covers one that doesn't.
|
||||||
|
main.postAtTime({ if (id == run && _state.value.until != 0L) cancel() }, until + OFFER_GRACE_MS)
|
||||||
CarAssistant.dictate(
|
CarAssistant.dictate(
|
||||||
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
|
||||||
|
onSpeaking = {
|
||||||
|
if (id != run) return@dictate
|
||||||
|
val s = _state.value
|
||||||
|
val left = ((s.until - SystemClock.uptimeMillis()).toFloat() / s.span).coerceIn(0.05f, 1f)
|
||||||
|
_state.value = s.copy(until = 0L, span = 0L, held = left)
|
||||||
|
},
|
||||||
onResult = { text ->
|
onResult = { text ->
|
||||||
if (id != run) return@dictate
|
if (id != run) return@dictate
|
||||||
if (isYes(text)) {
|
if (isYes(text)) {
|
||||||
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "")
|
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
|
||||||
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
|
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
|
||||||
} else {
|
} else {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Heard nothing: leave the Reply button up for a moment, the same as after reading.
|
// No answer: the card has already offered Reply and the quick replies all along.
|
||||||
onError = { if (id == run) idleAfterRead(id) },
|
onError = { if (id == run) finish() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,6 +375,9 @@ object VoiceReply {
|
|||||||
|
|
||||||
private val SEND_TOKEN = Any()
|
private val SEND_TOKEN = Any()
|
||||||
private const val ECHO_MS = 300L
|
private const val ECHO_MS = 300L
|
||||||
|
/** How long "Do you want to reply?" waits for an answer: the start beep, then the engine's silence limit. */
|
||||||
|
private const val OFFER_MS = 6_500L
|
||||||
|
private const val OFFER_GRACE_MS = 1_500L
|
||||||
/** After talk that wasn't a command, the reply waits this long again before it goes. */
|
/** After talk that wasn't a command, the reply waits this long again before it goes. */
|
||||||
private const val RESUME_MS = 3_000L
|
private const val RESUME_MS = 3_000L
|
||||||
}
|
}
|
||||||
@@ -455,28 +455,28 @@ class CarAppConnection(private val context: Context) {
|
|||||||
|
|
||||||
private val _surfaceEpoch = MutableStateFlow(0)
|
private val _surfaceEpoch = MutableStateFlow(0)
|
||||||
|
|
||||||
/** Bumped when the lent surface turns out to be dead, so the stage builds a fresh one. */
|
/** Bumped to throw the lent surface away, so the stage builds a fresh one. */
|
||||||
val surfaceEpoch: StateFlow<Int> = _surfaceEpoch.asStateFlow()
|
val surfaceEpoch: StateFlow<Int> = _surfaceEpoch.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lends the app its surface again: called when the map comes back into view. An app that
|
* Called as the map comes back into view: replaces the surface only if it died while the map
|
||||||
* draws through its own VirtualDisplay (Organic Maps does) can be left pointing at a surface
|
* was covered. A live one is left alone, because every hand-over costs the app its map - some
|
||||||
* that has since died, and draws black until it's told about a live one. Taking the surface
|
* (MapQuest) rebuild theirs from a world view and fly back to the car's position.
|
||||||
* back and lending it again repoints it; a surface that has itself died is replaced.
|
*
|
||||||
|
* Always a new surface, never the dead one lent again: an app that draws through its own
|
||||||
|
* VirtualDisplay (Flitsmeister does) builds its new display on a re-lent surface before
|
||||||
|
* releasing the old, and the old one's disconnect then takes the shared BufferQueue's
|
||||||
|
* connection with it - the map freezes on its last frame for good. The stage's key on
|
||||||
|
* [surfaceEpoch] swaps in a new SurfaceView, which lends itself as it comes up.
|
||||||
*/
|
*/
|
||||||
fun refreshSurface() {
|
fun replaceDeadSurface() {
|
||||||
main.post {
|
main.post {
|
||||||
val current = lent ?: return@post
|
val current = lent ?: return@post
|
||||||
if (!current.surface.isValid) {
|
if (current.surface.isValid) return@post
|
||||||
GearslipLog.w("host: the lent surface died; building a new one")
|
GearslipLog.w("host: the lent surface died; building a new one")
|
||||||
lent = null
|
lent = null
|
||||||
sendSurfaceDestroyed(current)
|
|
||||||
_surfaceEpoch.value = _surfaceEpoch.value + 1
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
GearslipLog.i("host: lending the surface again as the map comes back")
|
|
||||||
sendSurfaceDestroyed(current)
|
sendSurfaceDestroyed(current)
|
||||||
sendSurfaceAvailable()
|
_surfaceEpoch.value = _surfaceEpoch.value + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,9 +504,9 @@ class CarAppConnection(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The app draws for the whole surface; the visible area is what isn't covered by host
|
// The app draws for the whole surface; the visible area is what isn't covered by host
|
||||||
// chrome, and the stable area is the part that never gets covered. We keep our chrome
|
// chrome, and the stable area is the part that never gets covered. Both depend on the
|
||||||
// to a top bar, so both are the surface minus that bar - reported by the UI via
|
// template (see visibleAreaFor) and are reported by the UI via [reportAreas] once it
|
||||||
// [reportAreas] once it has measured. Until then the whole surface is fair game.
|
// has measured. Until then the whole surface is fair game.
|
||||||
val whole = Rect(0, 0, surface.width, surface.height)
|
val whole = Rect(0, 0, surface.width, surface.height)
|
||||||
reportAreas(whole, whole)
|
reportAreas(whole, whole)
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
|
|||||||
*/
|
*/
|
||||||
fun CarIcon?.image(context: Context): ImageBitmap? {
|
fun CarIcon?.image(context: Context): ImageBitmap? {
|
||||||
val icon = this?.icon ?: return null
|
val icon = this?.icon ?: return null
|
||||||
val key = icon.describe()
|
val key = icon.cacheKey(context)
|
||||||
iconCache.get(key)?.let { return it }
|
iconCache.get(key)?.let { return it }
|
||||||
val drawable: Drawable = runCatching { icon.loadDrawable(context) }
|
val drawable: Drawable = runCatching { icon.loadDrawable(context) }
|
||||||
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
.onFailure { GearslipLog.w("icon: loadDrawable threw for ${icon.describe()}: ${it.javaClass.simpleName}: ${it.message}") }
|
||||||
@@ -77,6 +77,18 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
|
|||||||
return bitmap
|
return bitmap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [describe], plus when the app was last installed for a resource icon. Resource ids are only
|
||||||
|
* numbers inside one build of the app: an update that adds a drawable renumbers the ones after it,
|
||||||
|
* so a cache keyed on the bare id kept drawing the old build's picture on the wrong button (Vela's
|
||||||
|
* new Saved button came up as its search glyph).
|
||||||
|
*/
|
||||||
|
private fun androidx.core.graphics.drawable.IconCompat.cacheKey(context: Context): String {
|
||||||
|
if (type != androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) return describe()
|
||||||
|
val installed = runCatching { context.packageManager.getPackageInfo(resPackage, 0).lastUpdateTime }.getOrDefault(0L)
|
||||||
|
return "${describe()}@$installed"
|
||||||
|
}
|
||||||
|
|
||||||
/** Enough of an [androidx.core.graphics.drawable.IconCompat] to tell log lines apart without dumping the whole object. */
|
/** Enough of an [androidx.core.graphics.drawable.IconCompat] to tell log lines apart without dumping the whole object. */
|
||||||
private fun androidx.core.graphics.drawable.IconCompat.describe(): String = when (type) {
|
private fun androidx.core.graphics.drawable.IconCompat.describe(): String = when (type) {
|
||||||
androidx.core.graphics.drawable.IconCompat.TYPE_URI,
|
androidx.core.graphics.drawable.IconCompat.TYPE_URI,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ object KnownApps {
|
|||||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||||
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
||||||
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
||||||
|
"com.mapquest.android.ace", // MapQuest: map, search, routing - works well
|
||||||
|
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
|
||||||
)
|
)
|
||||||
|
|
||||||
fun works(packageName: String): Boolean = packageName in working
|
fun works(packageName: String): Boolean = packageName in working
|
||||||
@@ -31,8 +33,10 @@ object KnownApps {
|
|||||||
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
|
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apps whose car screen refuses Gearslip but whose player works. Only the "· Browse" tile
|
* Apps whose car screen refuses Gearslip but whose player works. When the player covers the
|
||||||
* gets the red X. Car library 1.9 and later accepts only Google's host on a phone.
|
* app (see [playerWorks]) the launcher drops the dead "· Browse" tile entirely rather than
|
||||||
|
* show it; otherwise that tile gets a red X. Car library 1.9 and later accepts only Google's
|
||||||
|
* host on a phone.
|
||||||
*/
|
*/
|
||||||
private val brokenScreen = setOf(
|
private val brokenScreen = setOf(
|
||||||
"com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip
|
"com.spotify.music", // Spotify 9.1.86 and later: car library 1.9 rejects Gearslip
|
||||||
@@ -47,6 +51,7 @@ object KnownApps {
|
|||||||
private val hidden = setOf(
|
private val hidden = setOf(
|
||||||
"com.google.android.dialer", // Google Phone
|
"com.google.android.dialer", // Google Phone
|
||||||
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
|
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
|
||||||
|
"com.google.android.apps.maps", // Google Maps: shows as a messenger, and its car UI is Android Auto's own
|
||||||
)
|
)
|
||||||
|
|
||||||
fun isHidden(packageName: String): Boolean = packageName in hidden
|
fun isHidden(packageName: String): Boolean = packageName in hidden
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.media
|
|||||||
|
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.Rating
|
import android.media.Rating
|
||||||
@@ -19,6 +20,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.host.KnownApps
|
||||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -142,7 +144,7 @@ class CarMedia(private val context: Context) {
|
|||||||
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
enum class Phase { IDLE, CONNECTING, READY, REJECTED }
|
||||||
|
|
||||||
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
|
||||||
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS }
|
enum class Rejection { REFUSED, NEEDS_NOTIFICATION_ACCESS, NEEDS_APP_RUNNING }
|
||||||
|
|
||||||
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
private val _rejection = MutableStateFlow(Rejection.REFUSED)
|
||||||
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
|
||||||
@@ -236,7 +238,7 @@ class CarMedia(private val context: Context) {
|
|||||||
* is out of reach. Reading another app's session needs Notification access, which the driver
|
* is out of reach. Reading another app's session needs Notification access, which the driver
|
||||||
* grants once on the phone.
|
* grants once on the phone.
|
||||||
*/
|
*/
|
||||||
private fun attachToSession(app: MediaApp, attempt: Int = 0) {
|
private fun attachToSession(app: MediaApp, attempt: Int = 0, launched: Boolean = false) {
|
||||||
if (connecting != app) return
|
if (connecting != app) return
|
||||||
val manager = context.getSystemService(MediaSessionManager::class.java)
|
val manager = context.getSystemService(MediaSessionManager::class.java)
|
||||||
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
val listener = ComponentName(context, GearslipNotificationListener::class.java)
|
||||||
@@ -249,12 +251,19 @@ class CarMedia(private val context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
|
val limit = if (launched) SESSION_RETRIES_LAUNCHED else SESSION_RETRIES
|
||||||
// The app only publishes its session once its service has started; give it a moment.
|
// The app only publishes its session once its service has started; give it a moment.
|
||||||
if (attempt < SESSION_RETRIES) {
|
if (attempt < limit) {
|
||||||
main.postDelayed({ attachToSession(app, attempt + 1) }, SESSION_RETRY_MS)
|
main.postDelayed({ attachToSession(app, attempt + 1, launched) }, SESSION_RETRY_MS)
|
||||||
} else {
|
} else {
|
||||||
GearslipLog.w("media: ${app.label} has no active session to control")
|
GearslipLog.w("media: ${app.label} has no active session to control")
|
||||||
_rejection.value = Rejection.REFUSED
|
// A session-only app (Spotify) just isn't running - say so and offer to open it,
|
||||||
|
// rather than calling it unsupported.
|
||||||
|
_rejection.value = if (KnownApps.playerWorks(app.component.packageName)) {
|
||||||
|
Rejection.NEEDS_APP_RUNNING
|
||||||
|
} else {
|
||||||
|
Rejection.REFUSED
|
||||||
|
}
|
||||||
_phase.value = Phase.REJECTED
|
_phase.value = Phase.REJECTED
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -437,8 +446,38 @@ class CarMedia(private val context: Context) {
|
|||||||
_queue.value = emptyList()
|
_queue.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens [app] on the phone, then connects once it publishes its session. The driver asks for
|
||||||
|
* this from the "Open" button a session-only app (Spotify) shows when it isn't running yet.
|
||||||
|
*/
|
||||||
|
fun launchAndConnect(app: MediaApp) {
|
||||||
|
if (!openApp(app)) return
|
||||||
|
disconnect()
|
||||||
|
connecting = app
|
||||||
|
_phase.value = Phase.CONNECTING
|
||||||
|
// A cold start takes a few seconds to publish a session, so wait, then watch for longer.
|
||||||
|
main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings [app] to the foreground on the phone so it publishes its session. */
|
||||||
|
private fun openApp(app: MediaApp): Boolean {
|
||||||
|
val intent = context.packageManager.getLaunchIntentForPackage(app.component.packageName) ?: return false
|
||||||
|
return runCatching {
|
||||||
|
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
GearslipLog.i("media: opened ${app.label} to bring up its session")
|
||||||
|
true
|
||||||
|
}.getOrElse {
|
||||||
|
GearslipLog.w("media: could not open ${app.label}: ${it.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SESSION_RETRIES = 6
|
const val SESSION_RETRIES = 6
|
||||||
const val SESSION_RETRY_MS = 500L
|
const val SESSION_RETRY_MS = 500L
|
||||||
|
/** After opening a cold app, how long to wait before the first look for its session. */
|
||||||
|
const val LAUNCH_WAIT_MS = 1_500L
|
||||||
|
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
|
||||||
|
const val SESSION_RETRIES_LAUNCHED = 16
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}, handler)
|
}, handler)
|
||||||
|
|
||||||
projector.start(reader.surface, width, height, density) { CarUi() }
|
projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() }
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
projector.stop()
|
projector.stop()
|
||||||
|
|||||||
Reference in new issue
Block a user