29 changed files with 703 additions and 150 deletions

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
name: Bug report
description: Something in Gearslip broke or behaves wrong.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting. Check the [open issues](https://github.com/Seb3thehacker/gearslip/issues) first, in case someone has already reported it.
- type: input
id: car
attributes:
label: Car
description: Make, model, and year.
placeholder: 2019 Honda Civic
validations:
required: true
- type: dropdown
id: head-unit
attributes:
label: Head unit
options:
- The one the car came with
- A factory unit from another car
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
- A homemade or open-source unit, such as LIVI
- I don't know
validations:
required: true
- type: dropdown
id: connection
attributes:
label: Connection
options:
- USB cable
- Wireless dongle
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened
description: What you did, what you expected, and what happened instead.
validations:
required: true
- type: textarea
id: log
attributes:
label: Log
description: |
On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
The log records your phone, its Android version, the Gearslip version, and why the connection ended.
placeholder: Drop the log file here.
validations:
required: true
+65
View File
@@ -0,0 +1,65 @@
name: Car report
description: Tell us whether Gearslip works in your car, so others know before they install.
labels: ["car report"]
body:
- type: markdown
attributes:
value: |
Reports from working cars help as much as reports from broken ones.
- type: input
id: car
attributes:
label: Car
description: Make, model, and year.
placeholder: 2019 Honda Civic
validations:
required: true
- type: dropdown
id: head-unit
attributes:
label: Head unit
options:
- The one the car came with
- A factory unit from another car
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
- A homemade or open-source unit, such as LIVI
- I don't know
validations:
required: true
- type: dropdown
id: connection
attributes:
label: Connection
options:
- USB cable
- Wireless dongle
validations:
required: true
- type: dropdown
id: result
attributes:
label: Does it work?
options:
- Yes, everything I tried works
- Partly. It connects, but something is wrong
- No, it never connects
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes
description: What you tried, and anything that didn't work.
- type: textarea
id: log
attributes:
label: Log
description: |
Needed if the car doesn't work. On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
placeholder: Drop the log file here.
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
@@ -0,0 +1,23 @@
name: Feature request
description: Suggest something Gearslip should do.
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: What do you want to do?
description: Describe the problem, not the solution. "I can't see my next turn while music plays" helps more than "add a turn widget."
validations:
required: true
- type: textarea
id: idea
attributes:
label: How should it work?
description: Your idea for a fix, if you have one.
- type: textarea
id: apps
attributes:
label: Apps involved
description: Any apps this touches, such as a map or music app.
+18 -16
View File
@@ -6,25 +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
/docs/cert-sweep.sh
archive/
CLAUDE.md
*.pdf
extraction/
.claude/
todo
logs/
# Native build cache.
.cxx/
# Testing
/docs/cert-sweep.sh
extraction/
/cloned-repos
# Internal
*.pdf
todo
logs/
# For AI code
.claude/
CLAUDE.md
# Other
archive/
+51
View File
@@ -0,0 +1,51 @@
# Contributing to Gearslip
Thanks for helping. Bug reports from real cars are the most useful thing you can send, so
start there if you aren't sure where to begin.
## Reporting a bug
Open an issue on [GitHub](https://github.com/Seb3thehacker/gearslip/issues) and include:
- your car's make, model, and year, and whether the head unit is the original one; and
- the log from the drive. Open Logs on the phone app's home screen and tap Share log.
The log records your phone, its Android version, the Gearslip version, the head unit, and
why the connection ended. Most car bugs can't be fixed without it. Head units often report
only a part number, so the log can't name your car for you.
## Building
You need Android Studio, or the Android SDK with JDK 21. Gradle downloads the NDK, CMake,
and the speech library on the first build.
```
./gradlew :gearslip:assembleDebug
```
The APK lands at `build/Gearslip-debug.apk`. It installs as Gearslip Dev, next to the
regular app.
## Pull requests
Work happens on the `experimental` branch, which merges into `main` at each release. Base your
branch on `experimental`, and open your pull request against it.
- Keep each pull request to one change, and explain the problem it solves.
- Say how you tested it: which car or head unit, which phone, and which apps.
- Code written with AI tools is welcome. Read it, test it, and stand behind it as you would
your own.
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
permissions and on-device consent dialogs.
## Writing
Text that users read, such as screens, release notes, and docs, follows Strunk's
[*The Elements of Style*](https://www.gutenberg.org/ebooks/37134). In short: use the active
voice, state things positively, cut needless words, and use the serial comma.
## License
Gearslip is licensed under AGPL-3.0, and your contributions are too. The author also sells a
commercial license, described in [NOTICE.md](NOTICE.md). By opening a pull request, you
agree that your contribution may be offered under that license as well.
+28 -10
View File
@@ -25,8 +25,13 @@ mode.
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.
- **Notifications.** Read and reply to notifications from the car screen, using the same
on-screen keyboard.
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
each one aloud, and you reply by voice or with a quick reply. Speech runs on the phone,
so it needs no Google account.
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
The rest wait on the dashboard.
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
## Status
@@ -38,16 +43,23 @@ mode.
- **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.
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
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; two optional adb
commands in the setup guide reduce it to a one-time grant.
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.
## 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
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
-38
View File
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -32,8 +32,8 @@ android {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 31
targetSdk = 37
versionCode = 6
versionName = "0.1.0-alpha"
versionCode = 7
versionName = "0.1.10-alpha"
manifestPlaceholders["appLabel"] = "Gearslip"
}
@@ -10,6 +10,23 @@ object Changelog {
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
val releases = listOf(
Release(
7, "0.1.10-alpha",
listOf(
"The launcher groups your apps: Phone, Maps, Music, Messaging, Web, Screen sharing, and Settings.",
"To move an app, press and hold it on the launcher, then tap Move left or Move right.",
"MapQuest and Flitsmeister work in Gearslip and show the green check.",
"Spotify has one tile. If Spotify isn't open on the phone, tap Open Spotify to start it.",
"Flick a map and it glides on, as it does in Android Auto.",
"The map picks up where it left off when you come back from another screen.",
"Search boxes in map apps are larger and easier to read.",
"Map apps keep the right icons on their buttons after they update.",
"When Gearslip asks \"Do you want to reply?\", a bar shows how long it will listen. The bar stops while you speak.",
"Gearslip fits its picture to wide car screens and keeps it clear of the edges the car crops.",
"Gearslip answers the car's connection checks correctly. Some cars, Audis among them, hung up after a second without this.",
"Google Maps no longer shows up as a messaging app.",
),
),
Release(
6, "0.1.0-alpha",
listOf(
@@ -631,15 +631,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 ?: "?"} " +
@@ -685,6 +681,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
@@ -702,6 +712,13 @@ class GearslipRunner(
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(
@@ -800,9 +817,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(
@@ -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,
)
@@ -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,6 +38,15 @@ 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"
}
@@ -5,6 +5,11 @@ 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
@@ -125,6 +130,30 @@ private fun SurfaceHost(
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 {
@@ -188,3 +217,6 @@ private fun ChromeBar(
/** 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
@@ -83,12 +83,13 @@ fun CarHome() {
val navigator = LocalCarNavigator.current
val panel = navigator.sidePanel
// Coming back to the map from another screen: make sure the nav app is drawing into a live
// surface. The first composition is skipped, since the surface is only just being lent.
// 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.refreshSurface()
if (onHome && seenHome[0]) CarServices.nav.replaceDeadSurface()
if (onHome) seenHome[0] = true
}
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.
val messagingPackages = messagingApps.map { it.packageName }.toSet()
val nav = navApps.filter { it.component.packageName !in messagingPackages }.map {
val label = if (it.component.packageName in mediaPackages) "${it.label} · Browse" else it.label
Entry(label, iconOf(it.component.packageName), template = it)
}
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
@@ -157,6 +163,50 @@ internal fun rememberAllEntries(): List<Entry> {
return remember(installed) { BuiltInApps.entries + installed.orEmpty() }
}
/** The launcher's groups, in the order they're laid out. */
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS }
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
entry.builtIn?.let {
return when (it) {
BuiltInApps.Phone -> AppGroup.PHONE
BuiltInApps.Web -> AppGroup.WEB
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
else -> AppGroup.SETTINGS
}
}
if (entry.media != null) return AppGroup.MUSIC
if (entry.messaging != null) return AppGroup.MESSAGING
val app = entry.template ?: return AppGroup.OTHER
// A media app's "· Browse" tile sits next to its player.
if (app.component.packageName in mediaPackages) return AppGroup.MUSIC
return when (app.kind) {
"calling" -> AppGroup.PHONE
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
"messaging" -> AppGroup.MESSAGING
"settings" -> AppGroup.SETTINGS
else -> AppGroup.OTHER
}
}
/**
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
* group with Settings last of all, then with the driver's own moves from [custom] laid over it.
* Moved apps only trade places among themselves, so an app installed later still lands in its group.
*/
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>): List<Entry> {
val shown = all.filter { it.builtIn !== BuiltInApps.VehicleData || showVehicleData }
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
val grouped = shown.sortedWith(
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
)
if (custom.isEmpty()) return grouped
val rank = custom.withIndex().associate { (i, id) -> id to i }
fun Entry.rank() = componentId?.let { rank[it] }
val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
return grouped.map { if (it.rank() != null) moved.next() else it }
}
/** An app by the id a pin or the running list knows it by, built-in or installed. */
internal fun findEntry(id: String): Entry? =
BuiltInApps.entries.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
@@ -184,7 +234,7 @@ internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnviro
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
fun CarLauncher() {
val navigator = LocalCarNavigator.current
@@ -210,11 +260,8 @@ fun CarLauncher() {
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
}
// Gearslip's own apps first, then everything installed, then Settings last where it's expected.
val builtIns = all.filter { it.builtIn != null && (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) }
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)
val appOrder by CarSettings.appOrder.collectAsState()
val tiles = launcherOrder(all, showVehicleData, appOrder).map(::tileOf)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
@@ -34,6 +34,7 @@ object CarSettings {
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"
@@ -66,6 +67,7 @@ object CarSettings {
/** 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)
@@ -120,6 +122,9 @@ object CarSettings {
/** 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
val lastMedia: StateFlow<String?> = lastMediaFlow
@@ -197,6 +202,8 @@ object CarSettings {
}.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"
@@ -286,4 +293,9 @@ object CarSettings {
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(","))
}
}
@@ -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(
"${app.label} would not let Gearslip browse it.",
"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
* it's open - as a card in the middle of the screen with car-sized buttons, over a scrim that
* 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.
*/
@@ -218,6 +218,7 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
style = MaterialTheme.typography.titleMedium,
)
}
if (navigator.current == CarScreen.Apps) MoveButtons(id)
if (slot != null) {
GsButton(
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 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
@@ -246,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())
}
@@ -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
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(
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 = ChromeType.body,
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,
)
}
}
}
}
@@ -344,7 +391,7 @@ 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(
@@ -680,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
@@ -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) {
_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?") {
if (id != run) return@say
val until = SystemClock.uptimeMillis() + OFFER_MS
_state.value = _state.value.copy(until = until, span = OFFER_MS)
// The recognizer gives up on its own; this only covers one that doesn't.
main.postAtTime({ if (id == run && _state.value.until != 0L) cancel() }, until + OFFER_GRACE_MS)
CarAssistant.dictate(
onPartial = { text -> if (id == run) _state.value = _state.value.copy(heard = text) },
onSpeaking = {
if (id != run) return@dictate
val s = _state.value
val left = ((s.until - SystemClock.uptimeMillis()).toFloat() / s.span).coerceIn(0.05f, 1f)
_state.value = s.copy(until = 0L, span = 0L, held = left)
},
onResult = { text ->
if (id != run) return@dictate
if (isYes(text)) {
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "")
_state.value = _state.value.copy(phase = Phase.ASKING, heard = "", until = 0L, span = 0L, held = 0f)
CarAssistant.say("Go ahead.") { if (id == run) listen(id) }
} else {
finish()
}
},
// Heard nothing: leave the Reply button up for a moment, the same as after reading.
onError = { if (id == run) idleAfterRead(id) },
// No answer: the card has already offered Reply and the quick replies all along.
onError = { if (id == run) finish() },
)
}
}
@@ -362,6 +375,9 @@ object VoiceReply {
private val SEND_TOKEN = Any()
private const val ECHO_MS = 300L
/** How long "Do you want to reply?" waits for an answer: the start beep, then the engine's silence limit. */
private const val OFFER_MS = 6_500L
private const val OFFER_GRACE_MS = 1_500L
/** After talk that wasn't a command, the reply waits this long again before it goes. */
private const val RESUME_MS = 3_000L
}
@@ -455,28 +455,28 @@ class CarAppConnection(private val context: Context) {
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()
/**
* Lends the app its surface again: called when the map comes back into view. An app that
* draws through its own VirtualDisplay (Organic Maps does) can be left pointing at a surface
* that has since died, and draws black until it's told about a live one. Taking the surface
* back and lending it again repoints it; a surface that has itself died is replaced.
* 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 refreshSurface() {
fun replaceDeadSurface() {
main.post {
val current = lent ?: return@post
if (!current.surface.isValid) {
GearslipLog.w("host: the lent surface died; building a new one")
lent = null
sendSurfaceDestroyed(current)
_surfaceEpoch.value = _surfaceEpoch.value + 1
return@post
}
GearslipLog.i("host: lending the surface again as the map comes back")
if (current.surface.isValid) return@post
GearslipLog.w("host: the lent surface died; building a new one")
lent = null
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
// 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)
@@ -52,7 +52,7 @@ private val iconCache = android.util.LruCache<String, ImageBitmap>(200)
*/
fun CarIcon?.image(context: Context): ImageBitmap? {
val icon = this?.icon ?: return null
val key = icon.describe()
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}") }
@@ -77,6 +77,18 @@ fun CarIcon?.image(context: Context): ImageBitmap? {
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,
@@ -16,6 +16,8 @@ object KnownApps {
"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.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
@@ -31,8 +33,10 @@ object KnownApps {
fun playerWorks(packageName: String): Boolean = packageName in workingPlayer
/**
* Apps whose car screen refuses Gearslip but whose player works. Only the "· Browse" tile
* gets the red X. Car library 1.9 and later accepts only Google's host on a phone.
* 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
@@ -47,6 +51,7 @@ object KnownApps {
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
@@ -2,6 +2,7 @@ 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
@@ -19,6 +20,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
@@ -142,7 +144,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()
@@ -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
* 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)
@@ -249,12 +251,19 @@ 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
@@ -437,8 +446,38 @@ class CarMedia(private val context: Context) {
_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 {
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
}
}
@@ -105,7 +105,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
}
}, handler)
projector.start(reader.surface, width, height, density) { CarUi() }
projector.start(reader.surface, width, height, density, name = "gearslip-preview") { CarUi() }
onDispose {
projector.stop()