Author SHA1 Message Date
Seb3thehacker 5f9701ccff Release 0.0.05-alpha: guided setup, downloadable certificate, car phone app, media rework
- First run now asks for permissions one at a time, each on its own screen with an Allow or
  Skip, instead of firing every system dialog at once from a cold start
- The head unit certificate can be downloaded straight from the opencardev/aasdk repository
  with one tap, on top of the existing adb-staged and file-import paths; Settings shows which
  source is active and lets an imported or downloaded certificate be removed
- A phone app on the car screen: a keypad and a searchable contacts list, with a confirmation
  card before every call
- The web browser draws its own on-screen keyboard for a page's text fields, so a search box or
  address bar can be typed into from the car screen
- Media player rework: a dedicated Now Playing tab with a bigger, screen-size-aware layout, an
  Up Next tab showing the app's real queue, and Browse/Lyrics tabs that share the same rail
- Waze and Google Messages are marked broken instead of pretending to work; Google's own dialer,
  Assistant, and Android Auto entries are hidden from the car launcher entirely; Aardvark and
  Vivi Music are marked tested and working
- A help screen replaces the old wall of setup text on the home screen
- The display-calibration screen is gone - it did its job
2026-09-26 16:53:28 -07:00
Seb3thehacker 043dc11251 Add phone call handling on the car screen
Incoming calls now show the caller (name via contacts, else number) on
the car UI with answer/decline, using a call screening service so the
car sees a call before it rings. Also adds pinnable nav bar shortcuts,
lowers minSdk to 31 for wider device support, and bumps to 0.0.04-pre.
2026-09-26 11:43:09 -07:00
Seb3thehackerandClaude Sonnet 5 48be745659 README: explain the screen-share indicator for audio
Gearslip captures playback, not the screen, but Android's consent
dialog and persistent indicator look identical either way. Note why
the indicator appears, and correct the adjacent no-adb claim to
mention the two optional audio commands as the sole exception.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:18:39 -07:00
Seb3thehackerandClaude Sonnet 5 7e4b4d6b99 Send media audio to the car without a per-drive prompt
The head unit offers a media sink, but Android answers Setup with an
empty reply instead of a Config message. AudioLink now falls back to
the advertised sink after 2.5s. AudioCaptureService captures by audio
usage, not by app, so switching media apps costs nothing further and
one grant covers a whole drive.

A one-time adb command (PROJECT_MEDIA) removes the consent dialog
itself; the setup screen now includes it as a copyable step, next to
the existing mic-permission step.

Capture counts as a screen share, so Android 15 hides notification
contents while it runs. ScreenShareGuard lifts that protection for
the length of the capture, with a second adb grant
(WRITE_SECURE_SETTINGS), and restores it afterward, including after a
crash.

The phone keeps playing what it captures, so AudioCaptureService also
mutes STREAM_MUSIC for the drive and restores it on every exit path.

Adds a debug bench (BenchAudioSink, CarPreviewActivity --ez audio
true) for testing the audio path without a car, and a unit test that
rebuilds the real head unit's ServiceDiscoveryResponse from a logged
session to confirm the media and guidance sinks are found correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 00:11:59 -07:00
Seb3thehacker a8b49a3672 Release v0.0.03-pre: weather, template cards, media fallbacks, setup steps
- Weather from Open-Meteo with a place name, a forecast screen and a nav bar icon; a fresh
  location fix is requested when the last one is stale
- Map controls are solid, one size, and fade out after four seconds; pinch to zoom works
  because every finger now reaches the app
- Map templates draw their content in a floating card; toggle and radio rows show their state,
  the Back and Pan actions work, and details text and buttons are larger
- Home player gains Lyrics and Minimise buttons; minimised, it lives in the nav bar
- Media apps that hide their library show what they are and can still be controlled; apps that
  refuse Gearslip as a host say why
- Debug mode switch, session report in the log, and log sharing as a file attachment
- Launcher, phone apps, weather and calendar load at startup
- Web app opens DuckDuckGo by default
- Home screen lists the setup steps; the launcher marks apps tested and working
2026-09-21 01:14:49 -07:00
Seb3thehacker 94f8f23a56 updated broken links 2026-09-20 14:27:43 -07:00
Seb3thehacker e1732f8365 initial standards implemented + new docs like how to build apps 2026-09-20 13:40:32 -07:00
Seb3thehacker cb895f0658 Release v0.0.02-pre: car dashboard, nav bar rework, cached launcher
- Nav bar is icon-only and shorter, with a now-playing pill on non-home screens
- Clock opens a calendar and weather dashboard (adds READ_CALENDAR)
- Web page URL is now set from the car's Settings screen
- Launcher app scan and weather are cached
- Home player gains skip buttons and a larger cover
2026-09-20 03:00:25 -07:00
Seb3thehacker 5b7b9499ca Gearslip: phone-side Android Auto client 2026-09-20 02:09:47 -07:00
74 changed files with 6055 additions and 957 deletions

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Write signing key and properties
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > gearslip-release.jks
cat > keystore.properties <<EOF
storeFile=gearslip-release.jks
storePassword=${{ secrets.KEYSTORE_STORE_PASSWORD }}
keyAlias=${{ secrets.KEYSTORE_KEY_ALIAS }}
keyPassword=${{ secrets.KEYSTORE_KEY_PASSWORD }}
EOF
- name: Build release APK
run: ./gradlew :gearslip:assembleRelease
- name: Locate APK
id: apk
run: |
apk=$(find gearslip/build/outputs/apk/release -name "*.apk" | head -n1)
echo "path=$apk" >> "$GITHUB_OUTPUT"
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: ${{ steps.apk.outputs.path }}
generate_release_notes: true
+10 -1
View File
@@ -12,7 +12,16 @@ local.properties
# 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
*.keystore
keystore.properties
# Local investigation script; not meant for the public repo.
/docs/cert-sweep.sh
archive/
CLAUDE.md
CLAUDE.md
*.pdf
extraction/
.claude/
+39 -32
View File
@@ -4,36 +4,40 @@
Bring any app to your car screen.
## What this is
## What Gearslip is
Gearslip is a phone-side Android Auto client. It speaks the Android Auto protocol directly
to a car head unit over USB and acts as a **Car App Library host**: apps that ship an
Android Auto car UI (navigation and media apps) hand Gearslip a template, and Gearslip
renders it natively on the car screen, full quality, no phone-screen mirroring. A separate
screen-sharing mode is included for apps that don't ship a car UI at all.
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
directly to a car head unit over USB, with no Google software in the path. Gearslip acts
as a Car App Library host: an app that ships a car interface hands Gearslip a template,
and Gearslip renders that template natively on the car screen. Nothing is mirrored from
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing
mode.
Built by reverse-engineering the protocol for interoperability - getting a non-Google client
talking to a car head unit that only expects Google's own app.
The author reverse-engineered the protocol for interoperability, so that a client other
than Google's own can talk to a car head unit.
## Status
Real progress, with one hard limit that's worth knowing before you build on this:
- **Templated app hosting works.** All 16 template types the Car App Library defines are
rendered, including a keyboard Gearslip draws itself for search and sign-in. Verified
against several real navigation and media apps.
- **Media playback works.** Car-enabled media apps are browsable and controllable from the
car UI, with lyrics and playback state, over the same host connection.
- **The connection to a real head unit needs a certificate Google controls, and Gearslip
doesn't ship one.** A head unit only accepts a phone identity that chains to Google's
Automotive Link certificate authority - something only Google issues. Without one, Gearslip
still runs and renders everything correctly against a debug harness, but a real car will
reject the connection outright. See [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) for what was
tested, including one publicly-known leaked certificate that works on some older head
units and is rejected by newer firmware and by Google's own reference tooling.
- **No adb, root, or Shizuku is required to run it.** Every feature is gated on an ordinary
Android permission or an on-device consent dialog - nothing needs privileged access, on
the phone or in the car.
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
Library defines, and it draws its own keyboard for search and sign-in. It has been
tested against several real navigation and media apps.
- **Media playback works.** Gearslip lets you browse and control car-enabled media apps
from the car screen, with lyrics and playback state, over the same host connection.
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
requires a screen-share style consent dialog and indicator for this, even though
Gearslip never reads the screen; that grant is the only way a non-privileged app can
tap another app's audio. The consent normally repeats every drive; the app's own setup
steps include two optional adb commands that reduce it to a one-time grant.
- **A real head unit needs a certificate that Google controls.** A head unit accepts only
a phone identity that chains to Google's Automotive Link certificate authority, and
Google alone issues those certificates. Gearslip does not ship one. Without it,
Gearslip runs and renders correctly against a debug harness, but a real car rejects the
connection. A leaked certificate exists that older head units accept; newer firmware
and Google's reference tooling reject it.
- **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.
## Building
@@ -43,14 +47,17 @@ Real progress, with one hard limit that's worth knowing before you build on this
The APK lands at `build/Gearslip-debug.apk`.
## Further reading
A release build needs a signing key. Copy `keystore.properties.example` to
`keystore.properties`, fill in your own values, and run
`./gradlew :gearslip:assembleRelease`. Git ignores both the properties file and the
keystore. Without them, the debug build still works.
- [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) - the certificate investigation in full: what was
tried, what a real head unit does and doesn't accept, and why.
- [CUSTOM_HEADUNIT_SCOPING.md](CUSTOM_HEADUNIT_SCOPING.md) - the original scoping of what a
custom head unit client would need.
## Writing apps for 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.
## License
AGPL-3.0 - see [LICENSE](LICENSE). A separate commercial license is available for private
use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
commercial license for private use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
+421
View File
@@ -0,0 +1,421 @@
# Building apps for Gearslip
Gearslip renders three kinds of app on a car screen. This page specifies all three.
| 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 |
| [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
and it also runs on Android Auto. Write a media app when you ship audio. Write a Gearslip app
when you want a screen the Car App Library cannot describe.
Gearslip's package name is `app.seb3thehacker.gearslip`. It declares
`android.car.permission.TEMPLATE_RENDERER`.
---
## Templated apps
A templated app exports a `CarAppService`, describes each screen as a `Template`, and lets the
host draw it. Gearslip implements the host half of that protocol, so an existing Android Auto app
needs no Gearslip-specific code. Build it against the AndroidX Car App Library as you would for
any other host.
### Letting Gearslip in
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:
```xml
<!-- res/values/arrays.xml -->
<array name="hosts_allowlist">
<item>4cc56ebdd13a54d260eca9b7e2b2ae6b30e2f225bfda8f508f0709edf084948b,app.seb3thehacker.gearslip</item>
</array>
```
```java
@Override
public HostValidator createHostValidator() {
return new HostValidator.Builder(getApplicationContext())
.addAllowedHosts(R.array.hosts_allowlist)
.build();
}
```
That digest belongs to Gearslip's release signing certificate. A Gearslip you built yourself
carries your own debug key and a different digest, so read the fingerprint off the APK you
actually installed when an allowlist entry fails to match.
Write each entry as the digest first, then a comma, then the package name. The digest is the
SHA-256 of the host's signing certificate as 64 lowercase hex characters, with no colons and no
`0x` prefix. The builder lowercases the entry and strips spaces, but it keeps colons, so convert
`keytool` output before pasting it:
```
keytool -list -v -keystore <keystore> -alias <alias> \
| grep 'SHA256:' | cut -d' ' -f3 | tr -d ':' | tr 'A-Z' 'a-z'
```
Reading the installed APK is more reliable, because it fingerprints the key that actually signed
the build, and `apksigner` already prints the required format:
```
apksigner verify --print-certs Gearslip.apk | grep 'SHA-256 digest'
```
During development, `HostValidator.ALLOW_ALL_HOSTS_VALIDATOR` accepts every host. Ship a real
validator instead.
An app that refuses a host returns no binder at all. Gearslip reports that as
`the app refused to bind - host not accepted`. Your own side logs the reason under the tag
`CarApp.Val`, including the exact `addAllowedHost` call to add, so read logcat there first.
### Discovery
Gearslip finds templated apps by querying `androidx.car.app.CarAppService`, then re-querying with
each known category to recover which ones your intent filter matched. Declare the categories you
belong to, or Gearslip files you under `app`:
```xml
<service android:name=".MyCarAppService" android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.NAVIGATION" />
</intent-filter>
</service>
```
Gearslip recognises the `NAVIGATION`, `POI`, `PARKING`, `CHARGING`, `MESSAGING`, `CALLING`,
`SETTINGS`, `IOT` and `WEATHER` categories. Navigation apps start automatically on the home
screen when the driver used them last.
### The handshake
Gearslip offers car API level 8. It negotiates down to
`min(8, yourLatestCarAppApiLevel)` and refuses the app when that falls below your
`minCarAppApiLevel`, rather than drawing it wrongly. Keep your minimum at 8 or below.
### Templates Gearslip renders
Gearslip renders all sixteen templates. Ten describe their own content:
`ListTemplate`, `GridTemplate`, `PaneTemplate`, `MessageTemplate`, `LongMessageTemplate`,
`SearchTemplate`, `SignInTemplate`, `TabTemplate`, `SectionedItemTemplate`,
`MediaPlaybackTemplate`.
Six draw a map underneath and take chrome over the top:
`NavigationTemplate`, `MapWithContentTemplate`, `MapTemplate`, `PlaceListNavigationTemplate`,
`RoutePreviewNavigationTemplate`, `PlaceListMapTemplate`.
`SearchTemplate` and `SignInTemplate` need text. A phone keyboard cannot appear on the car's
virtual display, so Gearslip draws its own and feeds the result back through the template's
listener.
### Content limits
Gearslip reports these limits through the constraint service, and expects you to honour them:
| Limit | Value |
| --- | --- |
| Grid items | 12 |
| Pane rows | 4 |
| Route list rows | 3 |
| Anything else | 12 |
Gearslip enables app-driven refresh, so you may invalidate your own template without the driver
touching the screen.
### Drawing a map
A navigation app draws its own pixels. Gearslip lends it a real `Surface` taken from a
`SurfaceView` inside the car's virtual display, so your map reaches the head unit through the
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.
Your app receives four gestures, never raw touches: `onClick`, `onScroll`, `onFling` and
`onScale`. Coordinates are surface pixels. Scroll distances follow `GestureDetector`'s
convention, which reports the previous position minus the current one.
### Car hardware
A phone knows nothing about a car's sensors, so Gearslip answers every car-hardware request with
"unsupported". It answers rather than ignores: an app whose sensor manager waits for a reply
hangs when the host stays silent. Design for a host that reports no vehicle data at all.
Gearslip accepts the suggestions an app pushes and discards them, because no screen shows them
yet. Your app will not fail for offering them.
### Day and night
Gearslip decides whether it is dark outside and pushes that to your app as a configuration
change, with `UI_MODE_NIGHT_YES` or `UI_MODE_NIGHT_NO`. Take your map style from the
configuration the host reports. The configuration also carries the car's density, screen size and
orientation, and always sets `UI_MODE_TYPE_CAR`.
---
## Media apps
Gearslip draws the media UI itself: browsing, artwork, lyrics, transport controls and a
now-playing pill in the nav bar. Your app supplies a browse tree and a media session.
Gearslip lists a media app when it exports a `MediaBrowserService` **and** declares the Android
Auto meta-data. Both halves are required, because the service alone also appears on phone-only
players that expose a browser to Bluetooth and Wear:
```xml
<application>
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
</application>
```
```xml
<!-- res/xml/automotive_app_desc.xml -->
<automotiveApp>
<uses name="media" />
</automotiveApp>
```
Publish playback state, metadata, duration and artwork through your `MediaSession`, and support
play, pause, next, previous and seek. Gearslip reconnects to the app the driver used last and
can start it playing on connection.
---
## Gearslip apps
This section specifies protocol version 1. Gearslip does not yet implement it.
A Gearslip app describes a screen and lets Gearslip render it in Compose, using the car's theme,
the driver's chosen scale and the current day or night state. Your app draws nothing and receives
no surface. Everything the driver sees comes from the model below, which means your screen
matches the rest of the car UI and cannot be made unreadable.
Write a Gearslip app to reach the parts of the car UI the Car App Library does not cover. Write a
templated app if you need to draw a map, because version 1 lends no surface.
### Discovery
Export a service with the Gearslip action and declare the protocol version it speaks:
```xml
<service
android:name=".GearslipService"
android:exported="true">
<intent-filter>
<action android:name="app.seb3thehacker.gearslip.APP" />
</intent-filter>
<meta-data
android:name="app.seb3thehacker.gearslip.version"
android:value="1" />
</service>
```
Gearslip scans for these services once per car session and shows each one as a tile in the
launcher, under your application's own label and icon. To override either on the car screen, add
`app.seb3thehacker.gearslip.label` or `app.seb3thehacker.gearslip.icon`.
Gearslip binds to any app that declares this service. It asks the driver for nothing, exactly as
a head unit asks nothing before running an Android Auto app.
Gearslip binds your service when the driver opens your tile and unbinds it when they leave. Keep
no state that a rebind cannot rebuild.
### The interface
Copy these three files into `src/main/aidl/app/seb3thehacker/gearslip/api/`. A published artifact
will replace them.
```aidl
// IGearslipApp.aidl
package app.seb3thehacker.gearslip.api;
import app.seb3thehacker.gearslip.api.IGearslipHost;
import app.seb3thehacker.gearslip.api.IScreenCallback;
interface IGearslipApp {
/** Return the highest version both sides speak. Gearslip passes the version it offers. */
int negotiate(int hostVersion);
void onCreate(IGearslipHost host);
void onStart();
void onStop();
/** Describe the current screen. Answer through the callback, on any thread. */
void getScreen(IScreenCallback callback);
void onAction(String actionId);
void onTextInput(String fieldId, String text);
}
```
```aidl
// IGearslipHost.aidl
package app.seb3thehacker.gearslip.api;
interface IGearslipHost {
/** Tell Gearslip the screen changed. It calls getScreen again. */
void invalidate();
/** Open the car keyboard. The result arrives at onTextInput. */
void requestTextInput(String fieldId, String prompt, String initialText);
String getEnvironment();
String getNowPlaying();
/** One of play, pause, toggle, next, previous. */
void mediaCommand(String command);
/** Leave the app and return to the launcher. */
void finish();
}
```
```aidl
// IScreenCallback.aidl
package app.seb3thehacker.gearslip.api;
interface IScreenCallback {
void onScreen(String screenJson);
void onError(String message);
}
```
### Versioning
Gearslip calls `negotiate` first, passing the version it offers. Return the highest version you
also speak. Gearslip uses that version for the rest of the connection and drops the app when the
answer exceeds what it offered. This mirrors how Gearslip negotiates car API levels with
templated apps, and it lets either side move first.
Version 1 is the only version. Reject anything you do not recognise by returning 1.
### Lifecycle
1. Gearslip binds your service and calls `negotiate`.
2. Gearslip calls `onCreate`, handing you the host interface. Keep it.
3. Gearslip calls `onStart`, then `getScreen`.
4. You answer through `onScreen`. Gearslip renders it.
5. The driver taps something. Gearslip calls `onAction` with that element's id.
6. Your screen changes, so you call `invalidate`. Gearslip calls `getScreen` again.
7. The driver leaves. Gearslip calls `onStop` and unbinds.
Every call arrives on a binder thread. Answer `getScreen` promptly; Gearslip shows the previous
screen until you do. Gearslip coalesces repeated `invalidate` calls.
### The screen model
Answer `getScreen` with a JSON object.
```json
{
"protocol": 1,
"type": "list",
"title": "Stations",
"items": [
{ "id": "r6", "title": "BBC Radio 6", "subtitle": "Music", "icon": "play" },
{ "id": "r4", "title": "BBC Radio 4", "subtitle": "Speech", "icon": "play" }
],
"actions": [
{ "id": "refresh", "title": "Refresh", "icon": "refresh", "primary": true }
]
}
```
`type` takes one of four values:
| Type | Renders as | Uses |
| --- | --- | --- |
| `list` | A vertical list of rows | `items` |
| `grid` | A grid of tiles | `items` |
| `pane` | A short detail pane beside its actions | `items`, `actions` |
| `message` | A single message and its actions | `text`, `actions` |
An item takes `id`, `title`, and optionally `subtitle`, `icon` and `enabled`. Gearslip calls
`onAction` with the item's `id` when the driver taps it. An item with `"type": "input"` renders
as a text field carrying `value`; tapping it opens the car keyboard, and the result arrives at
`onTextInput` under the item's id.
An action takes `id`, `title`, and optionally `icon` and `primary`. Gearslip renders at most two
actions and emphasises the primary one.
`icon` names a built-in glyph: `play`, `pause`, `next`, `previous`, `search`, `settings`, `home`,
`apps`, `notifications`, `info`, `share`, `place`, `favorite`, `add`, `remove`, `refresh`,
`check`, `error`. Version 1 carries no images. Gearslip drops an unknown name and renders the
row without a glyph.
Gearslip truncates any list past the limits in [Content limits](#content-limits) and shortens
long strings. Send a screen that fits, rather than one Gearslip has to cut.
### Reading the car
`getEnvironment` returns the car's current state:
```json
{
"protocol": 1,
"darkOutside": true,
"moving": false,
"frameWidth": 800,
"frameHeight": 480,
"densityDpi": 160,
"batteryPercent": 82,
"charging": true,
"vehicle": "Uconnect"
}
```
`darkOutside` reports whether Gearslip believes it is dark, from the light sensor or the clock.
`moving` reports whether the phone's GPS shows the car moving. Both change during a session, so
read them each time you build a screen rather than caching them.
Treat `moving` as a comfort signal, not a safety guarantee. It reads false whenever the fix is
stale or missing, which includes tunnels and car parks.
### Controlling media
`getNowPlaying` returns what the car is playing, whichever app owns it:
```json
{ "title": "Sleepwalk", "artist": "Santo & Johnny", "playing": true, "durationMs": 142000 }
```
`mediaCommand` sends one of `play`, `pause`, `toggle`, `next` or `previous` to that app. Both
work regardless of which app is playing, so use them to put transport controls on your own
screen. Ship audio through a media app instead of driving these.
### Text input
A phone keyboard cannot appear on the car's virtual display, so Gearslip draws its own. Call
`requestTextInput` with a field id, a prompt and any existing text. Gearslip opens the keyboard
and calls `onTextInput` as the driver types, so treat each call as the current contents rather
than a final answer.
### What version 1 leaves out
Version 1 lends no surface, so no Gearslip app draws its own pixels. It carries no images beyond
the built-in glyphs. It places apps only in the launcher; contributing a dashboard card or a
home-screen pane comes later. Anything that needs its own rendering belongs in a templated app.
---
## Further reading
- [README.md](../README.md) - what Gearslip is and how to build it.
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
reference for templated apps.
+33 -3
View File
@@ -1,3 +1,4 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
@@ -13,16 +14,45 @@ plugins {
alias(libs.plugins.kotlin.compose)
}
// The release signing key, read from an untracked keystore.properties beside this file or at the
// repository root. Third-party apps allowlist Gearslip by the SHA-256 of this certificate, so the
// key must stay the same for the life of the project - and must never be committed.
val keystoreProperties = Properties().apply {
listOf(
rootProject.file("keystore.properties"),
project.file("keystore.properties"),
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
}
android {
namespace = "app.seb3thehacker.gearslip"
compileSdk = 37
defaultConfig {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 35
minSdk = 31
targetSdk = 37
versionCode = 2
versionName = "0.0.02-pre"
versionCode = 5
versionName = "0.0.05-alpha"
}
signingConfigs {
// Declared only when the properties file is present, so a clone without the key still
// builds debug; assembleRelease then falls back to unsigned rather than failing here.
if (keystoreProperties.getProperty("storeFile") != null) {
create("release") {
storeFile = rootProject.file(keystoreProperties.getProperty("storeFile"))
storePassword = keystoreProperties.getProperty("storePassword")
keyAlias = keystoreProperties.getProperty("keyAlias")
keyPassword = keystoreProperties.getProperty("keyPassword")
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
}
}
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
@@ -0,0 +1,62 @@
package app.seb3thehacker.gearslip
import app.seb3thehacker.gearslip.audio.CarAudio
import kotlin.math.abs
/**
* Debug-only stand-in for the head unit's media audio sink.
*
* The car end of the audio path can only be exercised in a car, but everything before it - the
* setting, the consent dialog, the capture service, and whether the media app allows itself to be
* heard at all - is phone-side and can be tested at a desk. This sink takes the place of
* [AudioLink] so that path runs to completion, and reports what arrived.
*
* adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \
* --ei w 800 --ei h 480 --ez audio true --ei snaps 10
*
* A healthy run logs a rising byte count and a peak well above zero. A byte count that rises with
* a peak of zero means the app refused to be captured.
*/
class BenchAudioSink(override val sampleRate: Int = 48_000, override val channels: Int = 2) : CarAudio.Link {
private var chunks = 0
private var bytes = 0L
private var peak = 0
private var reportedAt = 0L
override fun start() {
chunks = 0
bytes = 0
peak = 0
reportedAt = System.currentTimeMillis()
GearslipLog.i("bench sink: started - pretending to be a ${sampleRate}Hz x$channels car sink")
}
override fun stop() {
GearslipLog.i("bench sink: stopped after $chunks chunks / $bytes bytes")
}
override fun write(pcm: ByteArray, size: Int) {
chunks++
bytes += size
for (i in 0 until size - 1 step 2) {
val sample = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8)
peak = maxOf(peak, abs(sample.toShort().toInt()))
}
val now = System.currentTimeMillis()
if (now - reportedAt < REPORT_MS) return
reportedAt = now
val seconds = bytes / (sampleRate.toDouble() * channels * 2)
GearslipLog.i(
"bench sink: %d chunks / %d bytes (%.1fs of audio), peak %.3f%s".format(
chunks, bytes, seconds, peak / 32768f,
if (peak == 0) " - SILENT, the app is not letting itself be captured" else "",
),
)
peak = 0
}
companion object {
private const val REPORT_MS = 2_000L
}
}
@@ -9,6 +9,9 @@ import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import app.seb3thehacker.gearslip.audio.AudioCaptureService
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
@@ -31,6 +34,28 @@ class CarPreviewActivity : ComponentActivity() {
private var reader: ImageReader? = null
@Volatile private var latest: Bitmap? = null
private val audioPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) askAudioCapture() else CarAudio.denied()
}
private val audioConsent = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
) { result ->
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
AudioCaptureService.start(this, result.resultCode, data)
} else {
GearslipLog.w("preview: audio capture consent declined")
CarAudio.denied()
}
}
private fun askAudioCapture() {
val manager = getSystemService(android.media.projection.MediaProjectionManager::class.java)
runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) }
.onFailure { GearslipLog.e("preview: could not ask for audio capture", it); CarAudio.denied() }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GearslipLog.init(this)
@@ -58,6 +83,22 @@ class CarPreviewActivity : ComponentActivity() {
val p = ScreenProjector(this).also { projector = it }
p.start(imageReader.surface, w, h, dpi) { CarUi() }
// Stand in for the car's media sink, so the phone half of the audio path runs for real.
if (intent.getBooleanExtra("audio", false)) {
CarAudio.onConsentRequested = {
runOnUiThread {
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) {
askAudioCapture()
} else {
audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
}
}
}
CarAudio.attach(BenchAudioSink())
}
GearslipLog.i("preview: displayId=${p.displayId}")
if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false)
@@ -76,6 +117,27 @@ class CarPreviewActivity : ComponentActivity() {
GearslipLog.i("preview: wrote ${out.name}")
step++
}
// A two-finger pinch: "cx,cy,startGap,endGap" in projected pixels, after "pinchAfter" ms.
intent.getStringExtra("pinch")?.let { spec ->
val (cx, cy, gap0, gap1) = spec.split(",").map { it.toFloat() }
val pair = { gap: Float -> listOf(TouchPoint(3, cx - gap / 2, cy), TouchPoint(7, cx + gap / 2, cy)) }
val after = intent.getIntExtra("pinchAfter", 9000).toLong()
main.postDelayed({
snap()
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, listOf(pair(gap0)[0]))
p.dispatchTouch(MotionEvent.ACTION_POINTER_DOWN, 1, pair(gap0))
val steps = 14
for (i in 1..steps) main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_MOVE, 0, pair(gap0 + (gap1 - gap0) * i / steps))
}, 40L * i)
main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_POINTER_UP, 1, pair(gap1))
p.dispatchTouch(MotionEvent.ACTION_UP, 0, listOf(pair(gap1)[0]))
}, 40L * (steps + 1))
main.postDelayed({ snap() }, 40L * (steps + 1) + 2000)
}, after)
}
// Free-running mode: keep snapshotting on a timer so the display can be driven from
// outside (adb `am start --display` / `input -d`) while the harness records it.
val snaps = intent.getIntExtra("snaps", 0)
@@ -89,8 +151,9 @@ class CarPreviewActivity : ComponentActivity() {
snap()
taps.forEachIndexed { i, (x, y) ->
main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_DOWN, x, y)
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, x, y) }, 80)
val point = listOf(TouchPoint(0, x, y))
p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, point)
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, 0, point) }, 80)
}, 2500L * (i + 1))
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
}
@@ -101,6 +164,8 @@ class CarPreviewActivity : ComponentActivity() {
super.onDestroy()
projector?.stop()
reader?.close()
CarAudio.onConsentRequested = null
CarAudio.release()
CarEnvironment.stop()
}
}
+63 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" />
@@ -23,6 +24,36 @@
<!-- Capturing a media app's playback (to send to the car) is gated on this by the platform. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!--
Playback capture copies a stream rather than moving it, so the phone keeps playing the
music it is sending to the car. Silencing the phone's own speaker for the drive needs this.
-->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!--
Audio capture counts as a screen share, and Android hides notifications during one. The
protection can be switched off for the drive, but only with this, granted once over adb
(pm grant). Without it Gearslip works the same and notifications stay hidden.
-->
<uses-permission
android:name="android.permission.WRITE_SECURE_SETTINGS"
tools:ignore="ProtectedPermissions" />
<!--
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
and spam app too - an on-device consent, not a permission declared here.
-->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<!-- Placing a call from the car screen's dial pad or contacts list needs this. -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<!--
Declared, not just requested. Templated apps decide whether to talk to a host by asking
whether it holds this permission (HostValidator.isValidHost checks it before falling back
@@ -38,6 +69,10 @@
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
<queries>
<!-- Named directly: current builds have no launcher-category entry point for the MAIN/
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
whether the package is installed at all. -->
<package android:name="com.google.android.projection.gearhead" />
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -60,6 +95,17 @@
android:supportsRtl="true"
android:theme="@style/Theme.Gearslip">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.logshare"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/share_paths" />
</provider>
<!--
singleTop: the head unit can re-attach while we are already foregrounded, which
arrives via onNewIntent rather than a fresh instance.
@@ -131,5 +177,21 @@
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!--
Sees incoming calls before they ring, so the car screen can show the caller and
decline it. Inert until the driver sets Gearslip as the phone's caller ID and spam
app, under Settings > Apps > Default apps - the same consent a call-blocking app asks
for, not a privileged one.
-->
<service
android:name=".call.GearslipCallScreening"
android:exported="true"
android:label="Gearslip calls"
android:permission="android.permission.BIND_SCREENING_SERVICE">
<intent-filter>
<action android:name="android.telecom.CallScreeningService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -8,6 +8,9 @@ object AppSettings {
private const val PREFS = "gearslip_settings"
private const val KEY_STARTUP_URL = "startup_url"
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
private const val KEY_DEBUG_MODE = "debug_mode"
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -34,4 +37,32 @@ object AppSettings {
fun setSeenCompatWarning(context: Context) {
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
}
/** Whether the certificate setup screen has been deliberately skipped. */
fun hasSkippedCertSetup(context: Context): Boolean =
prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false)
fun setSkippedCertSetup(context: Context) {
prefs(context).edit().putBoolean(KEY_SKIPPED_CERT_SETUP, true).apply()
}
/** Whether the one-by-one permissions wizard has already run once. */
fun hasSeenPermissionsSetup(context: Context): Boolean =
prefs(context).getBoolean(KEY_SEEN_PERMISSIONS_SETUP, false)
fun setSeenPermissionsSetup(context: Context) {
prefs(context).edit().putBoolean(KEY_SEEN_PERMISSIONS_SETUP, true).apply()
}
/**
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
* preview) on the home screen. On by default while the project is pre-release.
*/
fun debugMode(context: Context): Boolean =
prefs(context).getBoolean(KEY_DEBUG_MODE, true)
fun setDebugMode(context: Context, value: Boolean) {
prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply()
GearslipLog.debug = value
}
}
@@ -0,0 +1,52 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
/**
* Fetches the publicly available head unit certificate and key from the opencardev/aasdk
* repository on GitHub. The app ships no certificates; it downloads them only when the
* user chooses to, the same way a browser would.
*/
object CertDownloader {
private val log = GearslipLog.tagged("DOWNL")
private const val BASE = "https://raw.githubusercontent.com/opencardev/aasdk/main/cert"
private const val CRT = "$BASE/headunit.crt"
private const val KEY = "$BASE/headunit.key"
private const val CONNECT_TIMEOUT_MS = 10_000
private const val READ_TIMEOUT_MS = 15_000
class Downloaded(val certificatePem: String, val privateKeyPem: String)
/** Fetches both files; throws with a readable message on any failure. */
fun fetch(): Downloaded {
log.i("fetching certificate from $BASE")
val crt = fetch(CRT, "certificate")
val key = fetch(KEY, "private key")
log.i("downloaded certificate (${crt.length} bytes) and key (${key.length} bytes)")
return Downloaded(crt, key)
}
private fun fetch(url: String, label: String): String {
val body = try {
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
}
conn.inputStream.use { input ->
val out = ByteArrayOutputStream()
val buf = ByteArray(4096)
var n: Int
while (input.read(buf).also { n = it } != -1) out.write(buf, 0, n)
out.toString(Charsets.UTF_8)
}
} catch (e: Exception) {
throw RuntimeException("could not download the $label: ${e.message}", e)
}
if (body.isBlank()) throw RuntimeException("the $label file is empty")
return body
}
}
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
import android.content.Context
import android.net.Uri
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.KeyStore
import java.security.cert.X509Certificate
@@ -14,7 +15,9 @@ import java.security.cert.X509Certificate
*
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
* copied into the app's private storage.
* 2. **Staged over adb** into the app's external files directory, the way the spike has
* 2. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
* user asks; the app never ships or hosts the certificate.
* 3. **Staged over adb** into the app's external files directory, the way the spike has
* always done it. Still honoured, so existing setups keep working untouched:
*
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
@@ -23,17 +26,18 @@ import java.security.cert.X509Certificate
*
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
* regenerating); a `phone.pass` file next to it overrides it.
* 3. A freshly generated self-signed cert, which a real head unit rejects (see
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
* SPIKE_FINDINGS.md).
*/
object CertProvider {
private val log = GearslipLog.tagged("CERT")
private const val P12_NAME = "phone.p12"
private const val PASS_NAME = "phone.pass"
private const val DEFAULT_PASSWORD = "aaspike"
private const val IMPORT_DIR = "identity"
enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED }
enum class Kind { IMPORTED, DOWNLOADED, ADB_STAGED, SELF_SIGNED }
class Identity(
val keyStore: KeyStore,
@@ -66,7 +70,14 @@ object CertProvider {
if (imported.isFile) {
runCatching {
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED)
}.onFailure { GearslipLog.e("could not load the imported certificate - trying the next source", it) }
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
}
val downloaded = downloadedFile(context, P12_NAME)
if (downloaded.isFile) {
runCatching {
return loadP12(downloaded, DEFAULT_PASSWORD, "downloaded certificate", Kind.DOWNLOADED)
}.onFailure { log.e("could not load the downloaded certificate - trying the next source", it) }
}
val dir = context.getExternalFilesDir(null)
@@ -80,7 +91,7 @@ object CertProvider {
DEFAULT_PASSWORD
}
return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED)
}.onFailure { GearslipLog.e("could not load $P12_NAME - falling back to self-signed", it) }
}.onFailure { log.e("could not load $P12_NAME - falling back to self-signed", it) }
}
return null
}
@@ -103,7 +114,7 @@ object CertProvider {
target.parentFile?.mkdirs()
staging.copyTo(target, overwrite = true)
importedFile(context, PASS_NAME).writeText(password)
GearslipLog.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
log.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
return identity
} finally {
staging.delete()
@@ -112,17 +123,84 @@ object CertProvider {
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
/** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */
/** Whether a downloaded certificate is stored (may or may not also be imported). */
fun hasDownloaded(context: Context): Boolean = downloadedFile(context, P12_NAME).isFile
/** Drops the imported identity; the next source in line (downloaded, adb-staged, then self-signed) takes over. */
fun removeImported(context: Context) {
importedFile(context, P12_NAME).delete()
importedFile(context, PASS_NAME).delete()
GearslipLog.i("removed the imported certificate")
log.i("removed the imported certificate")
}
/** Drops the downloaded identity. */
fun removeDownloaded(context: Context) {
downloadedFile(context, P12_NAME).delete()
downloadedFile(context, PASS_NAME).delete()
log.i("removed the downloaded certificate")
}
/** Whether any certificate is available (imported, downloaded, or adb-staged). */
fun hasAnyCert(context: Context): Boolean =
hasImported(context) || hasDownloaded(context) || hasAdbStaged(context)
private fun hasAdbStaged(context: Context): Boolean =
context.getExternalFilesDir(null)?.let { File(it, P12_NAME).isFile } == true
/**
* Validates raw PKCS#12 bytes with [password] and, if good, writes them as the imported
* identity. Used when the app assembles a certificate in memory (e.g. from a download)
* rather than importing from a file picker.
*/
fun importFromBytes(context: Context, p12Bytes: ByteArray, password: String): Identity {
val staging = File(context.cacheDir, "import-$P12_NAME")
try {
staging.writeBytes(p12Bytes)
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
val target = importedFile(context, P12_NAME)
target.parentFile?.mkdirs()
staging.copyTo(target, overwrite = true)
importedFile(context, PASS_NAME).writeText(password)
log.i("imported a certificate from bytes: ${identity.certificate.subjectX500Principal}")
return identity
} finally {
staging.delete()
}
}
/**
* Downloads the publicly available head unit certificate from the opencardev/aasdk
* repository, converts it from PEM to PKCS#12, and stores it ready for use.
* Runs synchronously (caller is responsible for threading). Throws on any failure.
*/
fun downloadAndImport(context: Context): Identity {
val download = CertDownloader.fetch()
val result = PemKeyParser.build(download.certificatePem, download.privateKeyPem)
val target = downloadedFile(context, P12_NAME)
target.parentFile?.mkdirs()
val p12Password = DEFAULT_PASSWORD
val keyStore = result.keyStore
val p12Bytes = ByteArrayOutputStream().use { stream ->
keyStore.store(stream, p12Password.toCharArray())
stream.toByteArray()
}
target.writeBytes(p12Bytes)
downloadedFile(context, PASS_NAME).writeText(p12Password)
log.i("downloaded and stored certificate: ${result.certificate.subjectX500Principal}")
return Identity(keyStore, result.certificate, p12Password.toCharArray(),
"downloaded certificate (opencardev/aasdk)", Kind.DOWNLOADED)
}
// The imported files live in the app's private internal storage (not the external files
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
private fun downloadedFile(context: Context, name: String) =
File(File(context.filesDir, "$IMPORT_DIR-downloaded"), name)
private fun importedPassword(context: Context): String =
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
@@ -12,9 +12,10 @@ import java.util.concurrent.TimeUnit
* the TLS self-test: make our own failures loud on the bench.
*/
object EncoderSelfTest {
private val log = GearslipLog.tagged("VIDEO")
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean {
GearslipLog.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
log.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===")
var codecConfig: ByteArray? = null
var frames = 0
@@ -59,37 +60,37 @@ object EncoderSelfTest {
val csd = codecConfig
when {
csd == null -> {
GearslipLog.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
log.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced")
false
}
frames == 0 -> {
GearslipLog.e("encoder self-test FAILED: no frames produced")
log.e("encoder self-test FAILED: no frames produced")
false
}
!sawKeyFrame -> {
GearslipLog.e("encoder self-test FAILED: no keyframe - the car needs one first")
log.e("encoder self-test FAILED: no keyframe - the car needs one first")
false
}
!sawResyncKeyFrame -> {
GearslipLog.e(
log.e(
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
"the resync-after-drop fix would silently do nothing on this device",
)
false
}
else -> {
GearslipLog.i(
log.i(
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
"requestSyncFrame() confirmed working",
)
GearslipLog.hex(" csd", csd, limit = 48)
GearslipLog.i("=== encoder self-test PASSED ===")
log.hex(" csd", csd, limit = 48)
log.i("=== encoder self-test PASSED ===")
true
}
}
} catch (t: Throwable) {
runCatching { source.stop() }
GearslipLog.e("encoder self-test FAILED with an exception", t)
log.e("encoder self-test FAILED with an exception", t)
false
}
}
@@ -26,6 +26,7 @@ import app.seb3thehacker.gearslip.mirror.TouchRelayService
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi
import app.seb3thehacker.gearslip.car.Prefetch
import app.seb3thehacker.gearslip.ui.GearslipApp
import app.seb3thehacker.gearslip.ui.GearslipTheme
import java.io.FileInputStream
@@ -93,35 +94,36 @@ class GearslipActivity : ComponentActivity(), Projection {
}
}
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
private val locationPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { }
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
private val calendarPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { }
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) !=
android.content.pm.PackageManager.PERMISSION_GRANTED
) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR)
// Location, calendar, the phone/contacts group, and the microphone are asked for one at
// a time by PermissionsSetupScreen instead of here, each only after the driver taps
// Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted,
// is both a bad first impression and unreliable: Android does not consistently show more
// than one system permission dialog fired back-to-back, which was silently dropping some.
enableEdgeToEdge()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
GearslipLog.init(this)
GearslipLog.installCrashHandler()
GearslipLog.i("Gearslip ready")
CarSettings.init(this)
// A capture that died without tidying up would leave notifications exposed to every
// later screen share. Nothing is capturing if the service is not running, so undo it.
if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) {
app.seb3thehacker.gearslip.audio.ScreenShareGuard.restore(this)
}
Prefetch.warm(this)
CarEnvironment.setPhoneTheme(resources.configuration)
setContent {
GearslipTheme {
GearslipApp(onDisconnect = ::closeAccessory)
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
}
}
@@ -141,6 +143,12 @@ class GearslipActivity : ComponentActivity(), Projection {
CarEnvironment.setPhoneTheme(newConfig)
}
// Throttled inside Prefetch, so this only does work if the last run was a while ago.
override fun onStart() {
super.onStart()
Prefetch.warm(this)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
@@ -159,6 +167,19 @@ class GearslipActivity : ComponentActivity(), Projection {
GearslipLog.flush()
}
/** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
private fun requestCallScreeningRole() {
val roleManager = getSystemService(android.app.role.RoleManager::class.java)
if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
GearslipLog.w("calls: call screening role unavailable on this phone")
return
}
if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
runCatching {
callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
}.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
}
// --- Media audio --------------------------------------------------------------------
private fun requestAudioCapture() {
@@ -268,6 +289,7 @@ class GearslipActivity : ComponentActivity(), Projection {
private fun closeAccessory() {
runner?.stop()
SessionReport.print()
runCatching { descriptor?.close() }
descriptor = null
runner = null
@@ -287,10 +309,11 @@ class GearslipActivity : ComponentActivity(), Projection {
}
}
override fun onTouch(action: Int, x: Float, y: Float) {
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
runOnUiThread {
if (PhoneMirror.active.value) relayTouch(action, x, y)
else screenProjector?.dispatchTouch(action, x, y)
// Mirroring drives one phone touch at a time, so it follows the first finger only.
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
else screenProjector?.dispatchTouch(action, actionIndex, points)
}
}
@@ -31,6 +31,13 @@ object GearslipLog {
private var file: File? = null
/**
* Set from [AppSettings.debugMode]. Off drops the noisy trace output (hex dumps, and
* logcat copies of info lines); warnings, errors and the file copy are always kept, so a
* crash report is never empty.
*/
@Volatile var debug = true
// What the live log screen shows. Bounded, and separate from the file, which always keeps
// everything. The flow carries only a version number so a burst of lines costs the UI one
// snapshot per frame rather than one list copy per line.
@@ -40,10 +47,13 @@ object GearslipLog {
val version: StateFlow<Int> = liveVersion
fun init(context: Context) {
debug = AppSettings.debugMode(context)
val dir = context.getExternalFilesDir(null) ?: return
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
file = File(dir, "gearslip-$name.log")
i("log file: ${file?.absolutePath}")
SessionReport.init(context)
i("debug mode: $debug")
}
fun snapshot(): List<String> = synchronized(live) { live.toList() }
@@ -61,13 +71,81 @@ object GearslipLog {
return if (f != null && f.isFile) f.readText() else snapshot().joinToString("\n")
}
fun i(message: String) = write("I", message)
/**
* The current session's log, with the session report on top, copied to the cache dir for
* sharing as an attachment (a whole log can exceed what an Intent may carry as text).
*/
fun shareableFile(context: Context): android.net.Uri {
val dir = File(context.cacheDir, "shared").apply { mkdirs() }
dir.listFiles()?.forEach { it.delete() }
val out = File(dir, "gearslip-log.txt")
out.writeText(SessionReport.render() + "\n\n" + fullText())
return androidx.core.content.FileProvider.getUriForFile(
context, "${context.packageName}.logshare", out,
)
}
fun w(message: String) = write("W", message)
fun i(message: String, tag: String = "APP") = write("I", message, tag)
fun e(message: String, t: Throwable? = null) {
write("E", if (t == null) message else "$message: ${t.javaClass.simpleName}: ${t.message}")
if (t != null) Log.e(TAG, message, t)
fun w(message: String, tag: String = "APP") = write("W", message, tag)
/** Logs the message, then the exception with its cause chain and top stack frames. */
fun e(message: String, t: Throwable? = null, tag: String = "APP") {
write("E", if (t == null) message else "$message: ${rootCause(t)}", tag)
if (t != null) {
Log.e(TAG, message, t)
stackTrace(t).lines().forEach { write("E", " $it", tag) }
}
}
/** A logger that stamps every line with [tag], so the log can be scanned by subsystem. */
fun tagged(tag: String) = Tagged(tag)
class Tagged(private val tag: String) {
fun i(message: String) = GearslipLog.i(message, tag)
fun w(message: String) = GearslipLog.w(message, tag)
fun e(message: String, t: Throwable? = null) = GearslipLog.e(message, t, tag)
fun hex(label: String, data: ByteArray, limit: Int = 256) = GearslipLog.hex(label, data, limit, tag)
fun verdict(headline: String, detail: String) = GearslipLog.verdict(headline, detail)
fun flush() = GearslipLog.flush()
}
/** "SSLHandshakeException: Received fatal alert: bad_certificate" - the deepest cause wins. */
fun rootCause(t: Throwable): String {
var cause: Throwable = t
while (cause.cause != null && cause.cause !== cause) cause = cause.cause!!
val top = "${t.javaClass.simpleName}: ${t.message}"
return if (cause === t) top else "$top <- ${cause.javaClass.simpleName}: ${cause.message}"
}
private fun stackTrace(t: Throwable, frames: Int = 12): String {
val out = StringBuilder()
var cause: Throwable? = t
var depth = 0
while (cause != null && depth < 4) {
if (depth > 0) out.append("caused by ${cause.javaClass.name}: ${cause.message}\n")
cause.stackTrace.take(frames).forEach { out.append("at $it\n") }
cause = cause.cause?.takeIf { it !== cause }
depth++
}
return out.toString().trimEnd()
}
/**
* Records an uncaught exception in the log file before the process dies, then lets the
* system's own handler run. Without it the file ends mid-sentence and the reason is lost.
*/
fun installCrashHandler() {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
SessionReport.fail(SessionReport.Category.CRASH, "uncaught in thread ${thread.name}", throwable)
e("FATAL uncaught exception in thread ${thread.name}", throwable)
SessionReport.print()
flush()
}
previous?.uncaughtException(thread, throwable)
}
}
/** Unmissable in a scrolling logcat - this is what the whole spike exists to print. */
@@ -89,7 +167,8 @@ object GearslipLog {
runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) }
}
fun hex(label: String, data: ByteArray, limit: Int = 256) {
fun hex(label: String, data: ByteArray, limit: Int = 256, tag: String = "APP") {
if (!debug) return
val shown = minOf(data.size, limit)
val sb = StringBuilder()
for (idx in 0 until shown) {
@@ -97,16 +176,16 @@ object GearslipLog {
if (idx % 16 == 15) sb.append('\n') else sb.append(' ')
}
val suffix = if (data.size > limit) "\n... (${data.size - limit} more bytes)" else ""
i("$label (${data.size} bytes)\n$sb$suffix")
i("$label (${data.size} bytes)\n$sb$suffix", tag)
}
private fun write(level: String, message: String) {
private fun write(level: String, message: String, tag: String = "APP") {
when (level) {
"W" -> Log.w(TAG, message)
"E" -> Log.e(TAG, message)
else -> Log.i(TAG, message)
else -> if (debug) Log.i(TAG, message)
}
val line = "${stamp.format(Date())} $level $message"
val line = "${stamp.format(Date())} $level ${tag.padEnd(5)} $message"
synchronized(live) {
live.addLast(line)
while (live.size > MAX_LIVE_LINES) live.removeFirst()
@@ -27,6 +27,7 @@ class GearslipRunner(
private val projection: Projection? = null,
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
) {
private val log = GearslipLog.tagged("PROTO")
private enum class State { WAIT_VERSION, TLS_HANDSHAKE, WAIT_AUTH, WAIT_SDR, DONE }
@@ -50,6 +51,7 @@ class GearslipRunner(
@Volatile private var dropped = 0
@Volatile private var inputChannelId: Int = -1
@Volatile private var audioLink: AudioLink? = null
private var audioMessagesSeen = 0
private var touchWidth = 0
private var touchHeight = 0
private var touchesSeen = 0
@@ -61,16 +63,18 @@ class GearslipRunner(
@Volatile private var lastKeyframeSentAt = 0L
fun run() {
GearslipLog.i("--- spike run starting ---")
SessionReport.begin()
log.i("--- session starting ---")
SessionStatus.connecting("Connected to the head unit")
GearslipLog.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
log.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})")
val identity = identityProvider()
val cert = identity.certificate
GearslipLog.i("phone certificate source: ${identity.source}")
GearslipLog.i(" subject = ${cert.subjectX500Principal}")
GearslipLog.i(" issuer = ${cert.issuerX500Principal}")
GearslipLog.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
log.i("phone certificate source: ${identity.source}")
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
log.i(" subject = ${cert.subjectX500Principal}")
log.i(" issuer = ${cert.issuerX500Principal}")
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
tls = PhoneTls(identity.keyStore, identity.password)
startWatchdog()
@@ -80,7 +84,7 @@ class GearslipRunner(
while (running) {
val read = input.read(chunk)
if (read < 0) {
GearslipLog.w("head unit closed the connection (EOF) while in state $state")
log.w("head unit closed the connection (EOF) while in state $state")
reportDisconnect()
return
}
@@ -92,11 +96,12 @@ class GearslipRunner(
}
}
} catch (t: Throwable) {
GearslipLog.e("transport failed in state $state", t)
log.e("transport failed in state $state", t)
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
reportDisconnect()
} finally {
running = false
GearslipLog.flush()
log.flush()
}
}
@@ -113,7 +118,7 @@ class GearslipRunner(
private fun handleFrame(frame: Frames.Frame) {
val payload = if (frame.encrypted) {
if (!tls.handshakeComplete) {
GearslipLog.w("encrypted frame arrived before the handshake finished - ignoring")
log.w("encrypted frame arrived before the handshake finished - ignoring")
return
}
tls.decrypt(frame.payload)
@@ -123,7 +128,7 @@ class GearslipRunner(
val message = assembler.offer(frame, payload) ?: return
if (message.size < 2) {
GearslipLog.w("runt message on channel ${frame.channel}")
log.w("runt message on channel ${frame.channel}")
return
}
@@ -138,9 +143,19 @@ class GearslipRunner(
} else if (frame.channel == inputChannelId) {
onInputMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) {
// The audio channel is the one still being reverse-engineered, and its first few
// replies are what tell us why. Raw bytes for those, then quiet.
if (audioMessagesSeen < AUDIO_TRACE_MESSAGES) {
audioMessagesSeen++
log.i(
"audio frame #$audioMessagesSeen: channel=${frame.channel} " +
"encrypted=${frame.encrypted} raw=${frame.payload.size}B decrypted=${message.size}B",
)
log.hex(" audio raw", message, limit = 64)
}
audioLink?.onMessage(messageId, body)
} else {
GearslipLog.i("ignoring message id $messageId on channel ${frame.channel}")
log.i("ignoring message id $messageId on channel ${frame.channel}")
}
return
}
@@ -152,41 +167,49 @@ class GearslipRunner(
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
MSG_PING_REQUEST -> onPingRequest(body)
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0)
else -> GearslipLog.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
else -> log.i("unhandled control message id=$messageId (${body.size} bytes) - continuing")
}
}
private fun onVersionRequest(body: ByteArray) {
if (body.size < 4) {
GearslipLog.w("VersionRequest too short (${body.size} bytes)")
log.w("VersionRequest too short (${body.size} bytes)")
return
}
val major = ((body[0].toInt() and 0xFF) shl 8) or (body[1].toInt() and 0xFF)
val minor = ((body[2].toInt() and 0xFF) shl 8) or (body[3].toInt() and 0xFF)
GearslipLog.i("<- VersionRequest: head unit speaks $major.$minor")
log.i("<- VersionRequest: head unit speaks $major.$minor")
SessionReport.protocolVersion(major, minor)
// Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
// asserting 1.6 is the permissive choice: we only need to reach service discovery,
// not to honour whatever that version implies.
// PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert;
// the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check
// is tied to the *negotiated* protocol version rather than baked into the firmware,
// claiming an older version makes the newer unit skip the check entirely. CLAIM_VERSION
// = null restores the previous echo-the-head-unit behaviour.
val claim = CLAIM_VERSION ?: (major to minor)
val response = byteArrayOf(
((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
0, 0, // STATUS_SUCCESS
)
send(MSG_VERSION_RESPONSE, response, encrypted = false)
GearslipLog.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
log.i(
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
if (CLAIM_VERSION != null) " [FORCED; head unit offered $major.$minor]" else "",
)
state = State.TLS_HANDSHAKE
SessionStatus.connecting("Securing the link")
GearslipLog.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
}
private fun onHandshake(body: ByteArray) {
GearslipLog.i("<- EncapsulatedSSL (${body.size} bytes)")
log.i("<- EncapsulatedSSL (${body.size} bytes)")
val replies = try {
tls.pumpHandshake(body)
} catch (t: Throwable) {
GearslipLog.e("TLS handshake threw", t)
GearslipLog.verdict(
log.e("TLS handshake threw", t)
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
log.verdict(
"NOT VIABLE (TLS rejected)",
"The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " +
"handshake_failure alert here means it validates the phone's certificate.",
@@ -196,31 +219,34 @@ class GearslipRunner(
"The head unit aborted the secure handshake. It validates the phone's certificate.",
)
state = State.DONE
SessionReport.print()
return
}
replies.forEach {
send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
GearslipLog.i("-> EncapsulatedSSL (${it.size} bytes)")
log.i("-> EncapsulatedSSL (${it.size} bytes)")
}
if (tls.handshakeComplete && state == State.TLS_HANDSHAKE) {
state = State.WAIT_AUTH
GearslipLog.i("awaiting AuthComplete - this carries the certificate verdict")
log.i("awaiting AuthComplete - this carries the certificate verdict")
}
}
private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("<- AuthComplete: status=$status")
log.i("<- AuthComplete: status=$status")
status?.let { SessionReport.auth(it) }
when (status) {
0 -> {
GearslipLog.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
log.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery")
state = State.WAIT_SDR
SessionStatus.connecting("Certificate accepted - discovering services")
sendServiceDiscoveryRequest()
}
-2 -> {
GearslipLog.verdict(
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name)
log.verdict(
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
"The head unit explicitly rejected the phone's self-signed certificate. " +
"This is a definitive no - it validates the phone against a trust chain " +
@@ -233,7 +259,8 @@ class GearslipRunner(
state = State.DONE
}
-3 -> {
GearslipLog.verdict(
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name)
log.verdict(
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
"Authentication rejected. Broader than a pure certificate error, but the " +
"practical answer is the same.",
@@ -245,7 +272,8 @@ class GearslipRunner(
state = State.DONE
}
else -> {
GearslipLog.verdict(
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name)
log.verdict(
"INCONCLUSIVE (AuthComplete status=$status)",
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
"before drawing any conclusion.",
@@ -260,13 +288,13 @@ class GearslipRunner(
val body = Protobuf.stringField(4, "Gearslip") + // label_text
Protobuf.stringField(5, Build.MODEL) // device_name
send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true)
GearslipLog.i("-> ServiceDiscoveryRequest (encrypted)")
log.i("-> ServiceDiscoveryRequest (encrypted)")
}
private fun onServiceDiscoveryResponse(body: ByteArray) {
GearslipLog.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
GearslipLog.i("head unit describes itself as:\n" + Protobuf.describe(body))
GearslipLog.verdict(
log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
log.i("head unit describes itself as:\n" + Protobuf.describe(body))
log.verdict(
"VIABLE",
"The head unit accepted the presented phone certificate, completed TLS, and is " +
"advertising its channels over the encrypted session. A phone-side client is " +
@@ -274,13 +302,15 @@ class GearslipRunner(
)
state = State.DONE
val info = ServiceDiscovery.findHeadUnitInfo(body)
GearslipLog.i("head unit: $info")
log.i("head unit: $info")
SessionReport.headUnit(info.toString())
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile
SessionReport.profile(profile?.name)
if (profile != null) {
GearslipLog.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}")
log.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}")
} else {
GearslipLog.i("no vehicle profile matched - default display settings (add one to vehicles.json)")
log.i("no vehicle profile matched - default display settings (add one to vehicles.json)")
}
CarEnvironment.setVehicle(profile?.name ?: "Unknown vehicle", profile?.insets ?: Insets.NONE)
SessionStatus.connecting("Starting video")
@@ -303,18 +333,19 @@ class GearslipRunner(
private fun startVideoChannel(serviceDiscoveryResponse: ByteArray) {
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
if (video == null) {
GearslipLog.w("no video service in the discovery response - cannot start Phase A")
log.w("no video service in the discovery response - cannot start Phase A")
SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name)
return
}
videoChannelId = video.serviceId
videoConfigs = video.configs
GearslipLog.i("video service: channel=${video.serviceId} codec=${video.codecName}")
log.i("video service: channel=${video.serviceId} codec=${video.codecName}")
video.configs.forEachIndexed { index, config ->
GearslipLog.i(" config[$index] = $config")
log.i(" config[$index] = $config")
}
if (video.codecType != CODEC_H264_BP) {
GearslipLog.w("head unit wants ${video.codecName}, not H264_BP - Phase B must match this")
log.w("head unit wants ${video.codecName}, not H264_BP - Phase B must match this")
}
openChannel(video.serviceId)
@@ -322,12 +353,12 @@ class GearslipRunner(
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
if (input == null) {
GearslipLog.w("no input service advertised - projection will be output-only")
log.w("no input service advertised - projection will be output-only")
} else {
inputChannelId = input.serviceId
touchWidth = input.touchWidth
touchHeight = input.touchHeight
GearslipLog.i(
log.i(
"input service: channel=${input.serviceId} touchscreen=" +
"${input.touchWidth}x${input.touchHeight}",
)
@@ -339,15 +370,23 @@ class GearslipRunner(
private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) {
val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse)
sinks.forEach { sink ->
GearslipLog.i(
log.i(
"audio sink: channel=${sink.serviceId} stream=${sink.streamName} codec=${sink.codecName} " +
sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
)
}
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run {
GearslipLog.w("no media audio sink advertised - media apps will play on the phone only")
log.w("no media audio sink advertised - media apps will play on the phone only")
SessionReport.audio(
"no MEDIA sink; head unit offered ${sinks.joinToString { it.streamName }.ifEmpty { "none" }}",
)
return
}
val first = media.configs.firstOrNull()
SessionReport.audio(
"MEDIA sink on channel ${media.serviceId}, ${media.codecName}, " +
"${first?.sampleRate ?: 48_000}Hz/${first?.bits ?: 16}bit/x${first?.channels ?: 2}",
)
audioLink = AudioLink(
media,
sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = media.serviceId) },
@@ -363,7 +402,7 @@ class GearslipRunner(
MSG_CHANNEL_OPEN_REQUEST, request, encrypted = true,
channel = serviceId, messageType = Frames.MESSAGE_CONTROL,
)
GearslipLog.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId")
log.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId")
}
/**
@@ -382,40 +421,42 @@ class GearslipRunner(
private fun onInputMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE ->
GearslipLog.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
log.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> {
val report = Wire.fields(body)
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
if (touch == null) {
GearslipLog.i("<- InputReport with no touch payload (key or rotary event)")
log.i("<- InputReport with no touch payload (key or rotary event)")
return
}
val touchFields = Wire.fields(touch)
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
val pointers = Wire.allBytes(touchFields, 1)
if (pointers.isEmpty()) return
if (pointers.size > 1 && touchesSeen < 3) {
GearslipLog.i("(multi-touch: ${pointers.size} pointers; using the first)")
}
val first = Wire.fields(pointers[0])
val rawX = Wire.varint(first, 1)?.toInt() ?: return
val rawY = Wire.varint(first, 2)?.toInt() ?: return
val size = videoConfigs.getOrNull(selectedConfigIndex ?: 0)?.pixelSize()
val x = clamp(rawX, size?.first)
val y = clamp(rawY, size?.second)
val points = pointers.mapIndexedNotNull { index, pointer ->
val fields = Wire.fields(pointer)
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
val id = Wire.varint(fields, 3)?.toInt() ?: index
TouchPoint(id, clamp(rawX, size?.first), clamp(rawY, size?.second))
}
if (points.isEmpty()) return
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
if (points.size > 1 && touchesSeen < 3) {
log.i("multi-touch: ${points.size} pointers, action=$action index=$actionIndex")
}
touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val note = if (x != rawX.toFloat() || y != rawY.toFloat()) " (clamped)" else ""
GearslipLog.i("<- Touch action=$action at ($rawX,$rawY)$note")
val at = points[actionIndex]
log.i("<- Touch action=$action at (${at.x.toInt()},${at.y.toInt()}) pointers=${points.size}")
}
projection?.onTouch(action, x, y)
projection?.onTouch(action, actionIndex, points)
}
else -> GearslipLog.i("<- unhandled input message id=$messageId (${body.size} bytes)")
else -> log.i("<- unhandled input message id=$messageId (${body.size} bytes)")
}
}
@@ -423,9 +464,9 @@ class GearslipRunner(
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("<- ChannelOpenResponse: status=$status")
log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) {
GearslipLog.verdict(
log.verdict(
"PHASE A FAILED (channel open rejected)",
"The head unit refused to open the video channel (status=$status).",
)
@@ -435,7 +476,7 @@ class GearslipRunner(
// Setup { required MediaCodecType type = 1 }
send(MSG_MEDIA_SETUP, Protobuf.varintField(1, CODEC_H264_BP.toLong()),
encrypted = true, channel = videoChannelId)
GearslipLog.i("-> Setup(codec=VIDEO_H264_BP)")
log.i("-> Setup(codec=VIDEO_H264_BP)")
}
MSG_MEDIA_CONFIG -> {
@@ -443,7 +484,7 @@ class GearslipRunner(
val status = Wire.varint(fields, 1)?.toInt()
val maxUnackedFromConfig = Wire.varint(fields, 2)?.toInt()
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint }
GearslipLog.i(
log.i(
"<- Config: status=$status (1=WAIT 2=READY) max_unacked=$maxUnackedFromConfig " +
"configuration_indices=$indices",
)
@@ -457,21 +498,25 @@ class GearslipRunner(
selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
} ?: offered.firstOrNull()
GearslipLog.i("selected config index $selectedConfigIndex from offered $offered")
log.i("selected config index $selectedConfigIndex from offered $offered")
SessionReport.video(
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
)
// VideoFocusRequestNotification { mode = 2; reason = 3 }
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
Protobuf.varintField(3, 1L)
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> VideoFocusRequest(mode=PROJECTED)")
log.i("-> VideoFocusRequest(mode=PROJECTED)")
}
MSG_VIDEO_FOCUS_NOTIFICATION -> {
val fields = Wire.fields(body)
GearslipLog.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
log.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}")
// The head unit repeats this while it waits for frames; only act once.
if (!videoStarted) {
videoStarted = true
GearslipLog.i("PHASE A COMPLETE - video focus granted; starting video source")
log.i("PHASE A COMPLETE - video focus granted; starting video source")
startVideoSource()
}
}
@@ -483,11 +528,11 @@ class GearslipRunner(
acksSeen++
lastAckAt = System.currentTimeMillis()
if (framesSent <= 3 || framesSent % 60 == 0) {
GearslipLog.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
log.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames")
}
}
else -> GearslipLog.i("<- unhandled video message id=$messageId (${body.size} bytes)")
else -> log.i("<- unhandled video message id=$messageId (${body.size} bytes)")
}
}
@@ -503,18 +548,18 @@ class GearslipRunner(
val index = selectedConfigIndex ?: 0
val config = videoConfigs.getOrNull(index)
if (config == null) {
GearslipLog.e("no video config at index $index - cannot start the encoder")
log.e("no video config at index $index - cannot start the encoder")
return
}
val (width, height) = config.pixelSize() ?: run {
GearslipLog.e("unsupported resolution ${config.resolutionName}")
log.e("unsupported resolution ${config.resolutionName}")
return
}
val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
Protobuf.varintField(2, index.toLong())
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
val useProjection = projection != null
videoSource = VideoSource(
@@ -524,8 +569,8 @@ class GearslipRunner(
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
onCodecConfig = { csd ->
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
GearslipLog.hex(" csd", csd, limit = 64)
log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
log.hex(" csd", csd, limit = 64)
},
onFrame = { data, presentationTimeUs, keyFrame ->
sendVideoFrame(data, presentationTimeUs, keyFrame)
@@ -538,7 +583,7 @@ class GearslipRunner(
projection.onSurfaceReady(surface, width, height, config.density)
SessionStatus.projecting()
}
}.onFailure { e -> GearslipLog.e("encoder start failed", e) }
}.onFailure { e -> log.e("encoder start failed", e) }
}
}
@@ -549,12 +594,12 @@ class GearslipRunner(
// looks identical to a frozen picture. Assume the window cleared and carry on.
val since = System.currentTimeMillis() - lastAckAt
if (since > ACK_STALL_MS) {
GearslipLog.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
log.w("no ack for ${since}ms with $inFlight in flight - resetting the window")
inFlight = 0
} else {
dropped++
if (dropped % 30 == 1) {
GearslipLog.w("dropping frame - $inFlight in flight (max $maxUnacked)")
log.w("dropping frame - $inFlight in flight (max $maxUnacked)")
}
// A dropped frame breaks the decoder's reference chain on the head unit -
// every frame after it is a P-frame assuming a picture that never arrived.
@@ -581,7 +626,7 @@ class GearslipRunner(
}
if (keyFrame) {
if (resyncRequested) {
GearslipLog.i("resync keyframe delivered after $resyncAttempts attempt(s)")
log.i("resync keyframe delivered after $resyncAttempts attempt(s)")
}
resyncRequested = false
resyncAttempts = 0
@@ -590,7 +635,7 @@ class GearslipRunner(
try {
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
} catch (e: java.io.IOException) {
if (running) GearslipLog.w("video send failed, stopping the encoder: ${e.message}")
if (running) log.w("video send failed, stopping the encoder: ${e.message}")
running = false
videoSource?.stop()
return
@@ -598,7 +643,7 @@ class GearslipRunner(
inFlight++
framesSent++
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
GearslipLog.i(
log.i(
"-> Data frame #$framesSent (${data.size} bytes, keyFrame=$keyFrame, " +
"ptsUs=$presentationTimeUs)",
)
@@ -615,7 +660,7 @@ class GearslipRunner(
private fun onPingRequest(body: ByteArray) {
val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime()
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete)
GearslipLog.i("<- PingRequest / -> PingResponse (keeping the session alive)")
log.i("<- PingRequest / -> PingResponse (keeping the session alive)")
}
private fun send(
@@ -662,6 +707,13 @@ class GearslipRunner(
}
private fun reportDisconnect() {
when (state) {
State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name)
State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name)
State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name)
State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name)
State.DONE -> {}
}
when (state) {
State.DONE -> SessionStatus.disconnected()
State.WAIT_VERSION -> SessionStatus.failed(
@@ -678,28 +730,29 @@ class GearslipRunner(
)
}
when (state) {
State.WAIT_VERSION -> GearslipLog.verdict(
State.WAIT_VERSION -> log.verdict(
"INCONCLUSIVE (no VersionRequest)",
"The head unit never opened the control channel. This is a connection problem, " +
"not a certificate verdict - check that the accessory was actually claimed.",
)
State.TLS_HANDSHAKE -> GearslipLog.verdict(
State.TLS_HANDSHAKE -> log.verdict(
"LIKELY NOT VIABLE (dropped during TLS)",
"The unit disconnected mid-handshake without sending an alert we could read. " +
"Rejection is the likeliest reading, but re-run once before trusting it.",
)
State.WAIT_AUTH -> GearslipLog.verdict(
State.WAIT_AUTH -> log.verdict(
"INCONCLUSIVE (dropped after TLS, before AuthComplete)",
"TLS completed but the unit disconnected before stating a verdict. Re-run.",
)
State.WAIT_SDR -> GearslipLog.verdict(
State.WAIT_SDR -> log.verdict(
"INCONCLUSIVE (accepted, then dropped before service discovery)",
"AuthComplete said success, so the certificate was accepted - but our " +
"ServiceDiscoveryRequest drew no reply. Suspect our own encryption or " +
"framing rather than the head unit.",
)
State.DONE -> GearslipLog.i("connection closed after the run completed")
State.DONE -> log.i("connection closed after the run completed")
}
SessionReport.print()
}
private fun startWatchdog() {
@@ -711,7 +764,7 @@ class GearslipRunner(
if (state != State.DONE) {
val idle = System.currentTimeMillis() - lastProgress
if (idle > 10_000) GearslipLog.w("stalled in state $state for ${idle / 1000}s")
if (idle > 10_000) log.w("stalled in state $state for ${idle / 1000}s")
}
// Stream health. Without this a drive cannot tell "streaming fine" from
@@ -720,7 +773,8 @@ class GearslipRunner(
val sent = framesSent
val fps = (sent - lastFrames) / 5.0
lastFrames = sent
GearslipLog.i(
SessionReport.stream("${"%.1f".format(fps)} fps, sent=$sent dropped=$dropped acked=$acksSeen")
log.i(
"video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
"acked=$acksSeen inFlight=$inFlight split=$splitMessages",
)
@@ -739,6 +793,13 @@ class GearslipRunner(
}
private companion object {
/**
* Force the protocol version Gearslip claims in VersionResponse, as (major, minor), or
* null to echo the head unit. 1.3 is what the 2018 Uconnect (which accepts the JVC cert)
* speaks. This is the 2025 identity-check downgrade experiment: see onVersionRequest.
*/
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
const val MSG_VERSION_REQUEST = 1
const val MSG_VERSION_RESPONSE = 2
@@ -770,6 +831,7 @@ class GearslipRunner(
const val ACTION_DOWN = 0
const val ACTION_UP = 1
const val ACK_STALL_MS = 2_000L
const val AUDIO_TRACE_MESSAGES = 6
const val PERIODIC_KEYFRAME_MS = 5_000L
}
}
@@ -0,0 +1,125 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayInputStream
import java.math.BigInteger
import java.security.KeyFactory
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.RSAPrivateCrtKeySpec
import java.security.spec.RSAPublicKeySpec
/**
* Converts a PEM-encoded X.509 certificate and PEM-encoded RSA PKCS#1 private key into a
* PKCS#12 [KeyStore], using only standard JCA APIs — no BouncyCastle.
*
* The DER parsing of the PKCS#1 key is done by hand (matching the same approach used in
* [SelfSignedCert] for writing). Android's Conscrypt [KeyFactory] only accepts PKCS#8,
* so we translate PKCS#1 → [RSAPrivateCrtKeySpec] ourselves.
*/
object PemKeyParser {
private const val CERT_HEADER = "-----BEGIN CERTIFICATE-----"
private const val CERT_FOOTER = "-----END CERTIFICATE-----"
private const val KEY_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
private const val KEY_FOOTER = "-----END RSA PRIVATE KEY-----"
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
/**
* Parses PEM strings for the certificate and private key, verifies they match, and
* returns a PKCS#12 [KeyStore] ready for [CertProvider] to consume.
*/
fun build(certificatePem: String, privateKeyPem: String, alias: String = "phone"): Result {
val cert = parseCertificate(certificatePem)
val keySpec = parsePrivateKey(privateKeyPem)
val kf = KeyFactory.getInstance("RSA")
val pubSpec = RSAPublicKeySpec(keySpec.spec.modulus, keySpec.spec.publicExponent)
require(kf.generatePublic(pubSpec) == cert.publicKey) {
"the certificate's public key does not match the private key"
}
val keyStore = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry(alias, keySpec.getPrivate(), CharArray(0), arrayOf(cert))
}
return Result(keyStore, cert)
}
/** Strips PEM headers/footers and decodes the base64 body. */
fun parseCertificate(pem: String): X509Certificate {
val der = pemToDer(pem, CERT_HEADER, CERT_FOOTER)
return CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
}
/** Parses an RSA PKCS#1 private key (ASN.1 DER) into a key spec. */
fun parsePrivateKey(pem: String): KeySpec {
val der = pemToDer(pem, KEY_HEADER, KEY_FOOTER)
val integers = readPkcs1Sequence(der)
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
val spec = RSAPrivateCrtKeySpec(integers[1], integers[2], integers[3], integers[4], integers[5], integers[6], integers[7], integers[8])
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec)
return KeySpec(spec, privateKey)
}
/** Holds both the spec and the constructed key for matching against a certificate's public key. */
class KeySpec(val spec: RSAPrivateCrtKeySpec, private val privateKey: java.security.PrivateKey) {
fun getPrivate() = privateKey
}
/** Strips headers and decodes base64. */
private fun pemToDer(pem: String, header: String, footer: String): ByteArray {
val start = pem.indexOf(header)
val end = pem.indexOf(footer)
if (start == -1 || end == -1 || end <= start) {
throw IllegalArgumentException("not a valid PEM $header block")
}
val b64 = pem.substring(start + header.length, end)
.replace(Regex("\\s+"), "")
return java.util.Base64.getDecoder().decode(b64)
}
/**
* Parses the inner SEQUENCE of an RSA PKCS#1 DER blob into a list of [BigInteger]s.
* Throws if the ASN.1 structure is wrong, so caller gets a clear error rather than a
* silent wrong key.
*/
private fun readPkcs1Sequence(der: ByteArray): List<BigInteger> {
var pos = 0
fun readTag(): Int {
require(pos < der.size) { "unexpected end of DER at tag" }
return der[pos++].toInt() and 0xFF
}
fun readLength(): Int {
require(pos < der.size) { "unexpected end of DER at length" }
val b0 = der[pos++].toInt() and 0xFF
if (b0 < 0x80) return b0
val numBytes = b0 and 0x7F
require(pos + numBytes <= der.size) { "truncated long-form length" }
var result = 0
repeat(numBytes) {
result = (result shl 8) or (der[pos++].toInt() and 0xFF)
}
return result
}
val outerTag = readTag()
require(outerTag == 0x30) { "expected SEQUENCE (0x30), got 0x${outerTag.toString(16)}" }
val outerLen = readLength()
require(pos + outerLen <= der.size) { "SEQUENCE length $outerLen exceeds remaining data" }
val integers = mutableListOf<BigInteger>()
while (pos < der.size) {
val tag = readTag()
require(tag == 0x02) { "expected INTEGER (0x02), got 0x${tag.toString(16)} at offset ${pos - 1}" }
val len = readLength()
require(pos + len <= der.size) { "INTEGER length $len exceeds remaining data" }
integers.add(BigInteger(der.copyOfRange(pos, pos + len)))
pos += len
}
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
return integers
}
}
@@ -29,6 +29,7 @@ import javax.net.ssl.X509ExtendedTrustManager
* the right tool.
*/
class PhoneTls(keyStore: KeyStore, password: CharArray) {
private val log = GearslipLog.tagged("TLS")
private val engine: SSLEngine
private var netIn = ByteArray(0)
@@ -156,7 +157,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
continue
}
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
GearslipLog.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
break
}
SSLEngineResult.Status.CLOSED -> break
@@ -173,26 +174,29 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
private fun describeSession() {
val session = engine.session
GearslipLog.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
log.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}")
SessionReport.tls(session.protocol, session.cipherSuite)
try {
val peers = session.peerCertificates
GearslipLog.i("head unit presented ${peers.size} certificate(s):")
log.i("head unit presented ${peers.size} certificate(s):")
peers.forEachIndexed { index, certificate ->
if (certificate is X509Certificate) {
GearslipLog.i(" [$index] subject=${certificate.subjectX500Principal}")
GearslipLog.i(" issuer =${certificate.issuerX500Principal}")
GearslipLog.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
log.i(" [$index] subject=${certificate.subjectX500Principal}")
log.i(" issuer =${certificate.issuerX500Principal}")
log.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
}
}
} catch (_: SSLPeerUnverifiedException) {
GearslipLog.i("head unit presented NO certificate (we asked but did not require one)")
log.i("head unit presented NO certificate (we asked but did not require one)")
}
}
/** Accepts everything and reports what it saw. We want the data, not a verdict. */
private class LoggingTrustManager : X509ExtendedTrustManager() {
private val log = GearslipLog.tagged("TLS")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
GearslipLog.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
log.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting")
}
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?, socket: Socket?) =
@@ -8,8 +8,7 @@ object ProjectedContent {
/**
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
* pre-Settings way, still honoured so existing setups keep working), then the built-in
* test page.
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
*/
fun resolve(context: Context): String {
val saved = AppSettings.startupUrl(context)
@@ -26,9 +25,12 @@ object ProjectedContent {
return url
}
}
return TEST_PAGE
return DEFAULT_URL
}
/** What the Web app opens until a page is chosen in Settings. */
const val DEFAULT_URL = "https://duckduckgo.com"
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
private fun normalize(value: String): String =
if (value.startsWith("<") || "://" in value) value else "https://$value"
@@ -17,9 +17,14 @@ interface Projection {
/**
* A touch from the head unit, already scaled into projected-video coordinates.
* [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6).
* identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6). [points] is every
* finger currently down, and [actionIndex] says which of them [action] is about, so a pinch
* arrives as it would on the phone's own screen.
*/
fun onTouch(action: Int, x: Float, y: Float)
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
fun onProjectionStopped()
}
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
class TouchPoint(val id: Int, val x: Float, val y: Float)
@@ -136,13 +136,41 @@ class ScreenProjector(private val context: Context) {
}
}
/** [action] is already an Android MotionEvent action; x/y are in projected coordinates. */
fun dispatchTouch(action: Int, x: Float, y: Float) {
/**
* [action] is already an Android MotionEvent action; [points] are in projected coordinates and
* hold every finger down, with [actionIndex] naming the one the action is about. Two fingers
* arrive as one gesture, so pinch and two-finger drags work as they do on the phone itself.
*/
fun dispatchTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
val target = root ?: return
if (points.isEmpty()) return
val now = SystemClock.uptimeMillis()
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now
val event = MotionEvent.obtain(downTime, now, action, x, y, 0)
val properties = Array(points.size) { i ->
MotionEvent.PointerProperties().apply {
id = points[i].id
toolType = MotionEvent.TOOL_TYPE_FINGER
}
}
val coords = Array(points.size) { i ->
MotionEvent.PointerCoords().apply {
x = points[i].x
y = points[i].y
pressure = 1f
size = 1f
}
}
// A second finger going down or up carries which finger it was in the action itself.
val encoded = when (action) {
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP ->
action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)
else -> action
}
val event = MotionEvent.obtain(
downTime, now, encoded, points.size, properties, coords,
0, 0, 1f, 1f, 0, 0, android.view.InputDevice.SOURCE_TOUCHSCREEN, 0,
)
try {
target.dispatchTouchEvent(event)
} finally {
@@ -0,0 +1,132 @@
package app.seb3thehacker.gearslip
import android.content.Context
import android.os.Build
/**
* The facts a bug report needs, gathered as a session runs and printed as one readable block
* when it ends. The log holds the full trace; this holds the answer to "what went wrong,
* on what car, on what phone" so nobody has to read the trace to find out.
*
* Nothing here identifies a person: no serial numbers, accounts, locations or accessory
* serials. The head unit and vehicle strings are what the head unit itself announces.
*/
object SessionReport {
/** Where a session failed, coarse enough to count and group reports by. */
enum class Category(val label: String) {
NONE("none"),
USB("USB transport"),
NO_HANDSHAKE("head unit never started talking"),
TLS("TLS handshake failed"),
CERTIFICATE("certificate rejected by the head unit"),
AUTH("authentication failed"),
SERVICE_DISCOVERY("service discovery failed"),
VIDEO("video stream failed"),
CRASH("app crash"),
UNKNOWN("unclassified"),
}
private var appVersion = "?"
private var appCode = 0L
@Volatile private var startedAt = 0L
@Volatile var category = Category.NONE; private set
@Volatile private var failure = ""
@Volatile private var protocolVersion = ""
@Volatile private var certSource = ""
@Volatile private var certIssuer = ""
@Volatile private var tlsProtocol = ""
@Volatile private var tlsCipher = ""
@Volatile private var authStatus: Int? = null
@Volatile private var headUnit = ""
@Volatile private var profile = ""
@Volatile private var video = ""
@Volatile private var audio = ""
@Volatile private var lastStream = ""
@Volatile private var lastState = ""
private var printed = false
fun init(context: Context) {
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
appVersion = info?.versionName ?: "?"
appCode = info?.longVersionCode ?: 0
}
fun begin() {
startedAt = System.currentTimeMillis()
category = Category.NONE
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
tlsProtocol = ""; tlsCipher = ""; authStatus = null
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
printed = false
}
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" }
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
fun auth(status: Int) { authStatus = status }
fun headUnit(info: String) { headUnit = info }
fun profile(name: String?) { profile = name ?: "none matched" }
fun video(summary: String) { video = summary }
fun audio(summary: String) { audio = summary }
fun stream(summary: String) { lastStream = summary }
/** Records the first failure only: later ones are usually fallout from it. */
fun fail(category: Category, detail: String, state: String = "") {
if (this.category != Category.NONE) return
this.category = category
failure = detail
lastState = state
}
fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
fail(category, "$detail (${GearslipLog.rootCause(t)})", state)
/** The report as plain text, for the log file and for anything that gets sent. */
fun render(): String {
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
fun row(name: String, value: String) = if (value.isEmpty()) "" else " ${name.padEnd(16)}$value\n"
return buildString {
append("=".repeat(60)).append('\n')
append("SESSION REPORT\n")
append(row("result", if (category == Category.NONE) "ok / no failure recorded" else "FAILED - ${category.label}"))
append(row("detail", failure))
append(row("failed in state", lastState))
append(row("duration", "${seconds}s"))
append("\n")
append(row("gearslip", "$appVersion (code $appCode)"))
append(row("phone", "${Build.MANUFACTURER} ${Build.MODEL}"))
append(row("android", "${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT}, patch ${Build.VERSION.SECURITY_PATCH})"))
append("\n")
append(row("head unit", headUnit))
append(row("AA protocol", protocolVersion))
append(row("profile", profile))
append("\n")
append(row("phone cert", certSource))
append(row("cert issuer", certIssuer))
append(row("TLS", listOf(tlsProtocol, tlsCipher).filter { it.isNotEmpty() }.joinToString(" / ")))
append(row("auth status", authStatus?.let { "$it (${authName(it)})" } ?: ""))
append("\n")
append(row("video", video))
append(row("audio", audio))
append(row("last stream stat", lastStream))
append("=".repeat(60))
}
}
/** Prints the report into the log, once per session. */
fun print() {
if (printed || startedAt == 0L) return
printed = true
render().lines().forEach { GearslipLog.i(it) }
GearslipLog.flush()
}
private fun authName(status: Int) = when (status) {
0 -> "success"
-2 -> "certificate error"
-3 -> "authentication failure"
else -> "unknown"
}
}
@@ -21,12 +21,13 @@ import javax.net.ssl.X509ExtendedTrustManager
* unit, not about this code.
*/
object TlsSelfTest {
private val log = GearslipLog.tagged("TLS")
fun run(identity: CertProvider.Identity): Boolean {
GearslipLog.i("=== TLS self-test starting ===")
GearslipLog.i("cert source: ${identity.source}")
GearslipLog.i(" subject = ${identity.certificate.subjectX500Principal}")
GearslipLog.i(" issuer = ${identity.certificate.issuerX500Principal}")
log.i("=== TLS self-test starting ===")
log.i("cert source: ${identity.source}")
log.i(" subject = ${identity.certificate.subjectX500Principal}")
log.i(" issuer = ${identity.certificate.issuerX500Principal}")
return try {
val server = PhoneTls(identity.keyStore, identity.password)
val client = Driver(clientEngine())
@@ -44,7 +45,7 @@ object TlsSelfTest {
}
if (!server.handshakeComplete) {
GearslipLog.e("self-test FAILED: handshake did not complete after $rounds rounds")
log.e("self-test FAILED: handshake did not complete after $rounds rounds")
return false
}
@@ -53,14 +54,14 @@ object TlsSelfTest {
val plaintext = "gearslip round trip".toByteArray()
val decrypted = client.decrypt(server.encrypt(plaintext))
if (!decrypted.contentEquals(plaintext)) {
GearslipLog.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
log.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)")
return false
}
GearslipLog.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
log.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===")
true
} catch (t: Throwable) {
GearslipLog.e("self-test FAILED with an exception", t)
log.e("self-test FAILED with an exception", t)
false
}
}
@@ -26,6 +26,7 @@ class VideoSource(
private val onCodecConfig: (ByteArray) -> Unit,
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
) {
private val log = GearslipLog.tagged("VIDEO")
/**
* TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for
@@ -67,11 +68,11 @@ class VideoSource(
codec = encoder
running = true
GearslipLog.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
log.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
worker = Thread {
runCatching { pump(encoder) }
.onFailure { if (running) GearslipLog.e("encoder loop failed", it) }
.onFailure { if (running) log.e("encoder loop failed", it) }
}.apply { name = "gearslip-encoder"; start() }
}
@@ -89,7 +90,7 @@ class VideoSource(
codec?.setParameters(Bundle().apply {
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
})
}.onFailure { GearslipLog.w("requestSyncFrame failed: ${it.message}") }
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
}
fun stop() {
@@ -24,16 +24,51 @@ import kotlin.math.abs
* Playback capture is the one public route to another app's sound, and it hangs off a
* MediaProjection - so, as with [app.seb3thehacker.gearslip.mirror.MirrorService], the consent
* result is carried into a foreground service of type mediaProjection before it can be redeemed.
* Capture is scoped to the media app's uid: notification pings and other apps stay on the phone.
* Capture is scoped to media playback: notification pings, calls and alarms stay on the phone.
* The grant lasts as long as this service does, which is as long as the car is connected, so the
* driver answers the consent dialog once per drive rather than once per app.
*/
class AudioCaptureService : Service() {
@Volatile private var reading = false
private var projection: MediaProjection? = null
private var thread: Thread? = null
private var mutedPhone = false
/**
* Silence the phone's own speaker while the car is being fed.
*
* Playback capture copies the stream instead of moving it, so without this the music plays in
* two places at once. Muting the stream does not affect what is captured - the tap sits before
* the volume the speaker gets - so the car still receives full-level audio.
*/
private fun mutePhone() {
if (mutedPhone) return
runCatching {
getSystemService(android.media.AudioManager::class.java)
.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_MUTE, 0)
mutedPhone = true
GearslipLog.i("audio: muted the phone's speaker for the drive")
}.onFailure { GearslipLog.w("audio: could not mute the phone: ${it.message}") }
}
private fun unmutePhone() {
if (!mutedPhone) return
mutedPhone = false
runCatching {
getSystemService(android.media.AudioManager::class.java)
.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_UNMUTE, 0)
GearslipLog.i("audio: gave the phone's speaker back")
}.onFailure { GearslipLog.w("audio: could not unmute the phone: ${it.message}") }
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
running = true
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopSelf()
@@ -49,6 +84,7 @@ class AudioCaptureService : Service() {
}
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
ScreenShareGuard.lift(this)
projection = try {
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
} catch (t: Throwable) {
@@ -74,13 +110,14 @@ class AudioCaptureService : Service() {
private fun beginCapture(token: MediaProjection) {
val rate = CarAudio.sampleRate
val channels = CarAudio.channels
val uid = CarAudio.uid
val record = try {
// Matched by usage, not by app. A uid filter would have to be rebuilt every time the
// driver changed media app, and rebuilding means a fresh consent dialog - so the
// filter is what the sound is for, and any app playing media reaches the car.
val config = AudioPlaybackCaptureConfiguration.Builder(token)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.apply { if (uid >= 0) addMatchingUid(uid) }
.build()
val mask = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
val format = AudioFormat.Builder()
@@ -106,17 +143,25 @@ class AudioCaptureService : Service() {
val buffer = ByteArray(CHUNK_BYTES)
var peak = 0
var heardAt = System.currentTimeMillis()
val startedAt = heardAt
var everHeard = false
var warnedSilent = false
try {
record.startRecording()
GearslipLog.i("audio: capturing uid=$uid at ${rate}Hz x$channels")
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
mutePhone()
CarAudio.captureStarted()
while (reading) {
val n = record.read(buffer, 0, buffer.size)
if (n <= 0) break
if (n <= 0) {
GearslipLog.e("audio: AudioRecord.read returned $n - capture has stopped")
break
}
for (i in 0 until n - 1 step 2) {
val sample = (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8)
peak = maxOf(peak, abs(sample.toShort().toInt()))
}
if (peak > 0) everHeard = true
val now = System.currentTimeMillis()
val level = peak / 32768f
if (now - heardAt >= LEVEL_LOG_MS) {
@@ -124,6 +169,16 @@ class AudioCaptureService : Service() {
heardAt = now
peak = 0
}
// Capture never fails for an app that opts out; it just hands back digital
// silence. Say so, because the symptom is identical to a broken car link.
if (!everHeard && !warnedSilent && now - startedAt >= SILENCE_MS) {
warnedSilent = true
GearslipLog.w(
"audio: ${SILENCE_MS / 1000}s of capture and every sample is zero. " +
"Either nothing is playing, or the app does not allow its audio " +
"to be captured.",
)
}
CarAudio.deliver(buffer, n, level)
}
} catch (t: Throwable) {
@@ -131,6 +186,7 @@ class AudioCaptureService : Service() {
} finally {
runCatching { record.stop() }
record.release()
unmutePhone()
CarAudio.captureStopped()
}
}, "gearslip-audio").apply { isDaemon = true; start() }
@@ -140,8 +196,13 @@ class AudioCaptureService : Service() {
super.onDestroy()
reading = false
runCatching { thread?.join(500) }
// Belt and braces: a phone left muted after the drive is a bug the driver blames on their
// phone, not on Gearslip, so unmute on every way out of here.
unmutePhone()
runCatching { projection?.stop() }
projection = null
ScreenShareGuard.restore(this)
running = false
}
private fun notification(): Notification {
@@ -173,9 +234,14 @@ class AudioCaptureService : Service() {
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
private const val CHUNK_BYTES = 4096
private const val LEVEL_LOG_MS = 3_000L
private const val SILENCE_MS = 5_000L
private var appContext: Context? = null
/** True between onCreate and onDestroy; lets the activity tell a live capture from a crashed one. */
@Volatile var running = false
private set
fun start(context: Context, resultCode: Int, data: Intent) {
appContext = context.applicationContext
context.startForegroundService(
@@ -27,10 +27,16 @@ class AudioLink(
private var configIndex = 0
@Volatile private var ready = false
@Volatile private var started = false
@Volatile private var focusGranted = false
@Volatile private var focusRequested = false
@Volatile private var inFlight = 0
@Volatile private var maxUnacked = 8
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
@Volatile private var maxUnacked = 4
@Volatile private var lastAckAt = 0L
private var chunks = 0
private var bytes = 0L
private var dropped = 0
@Volatile private var configTimer: Thread? = null
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
override val sampleRate get() = config?.sampleRate ?: 48_000
@@ -39,12 +45,47 @@ class AudioLink(
val channelId get() = service.serviceId
fun opened(status: Int) {
GearslipLog.i("audio: <- ChannelOpenResponse (media audio): status=$status")
if (status != 0) {
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)")
return
}
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
GearslipLog.i("audio: -> Setup(codec=${service.codecName})")
// AVChannelSetupRequest { required uint32 config_index = 1 }. This field is an index into
// the sink's own audio_configs, not a codec id - sending the codec type asks for a config
// the sink does not have, and it answers with a status that never reaches READY.
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
GearslipLog.i("audio: -> Setup(config_index=0) on channel ${service.serviceId}, codec ${service.codecName}")
armConfigFallback()
}
/**
* The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no
* body) rather than the AVChannelSetupResponse the video channel gets, so the sink never
* reaches READY and no audio is ever sent.
*
* Service discovery already told us everything that message would have: the sink is PCM at
* 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a
* message that is not coming, assume the advertised configuration and carry on. If that guess
* is wrong the head unit simply will not ack, which the write path already reports.
*/
private fun armConfigFallback() {
val timer = Thread({
try {
Thread.sleep(CONFIG_WAIT_MS)
} catch (e: InterruptedException) {
return@Thread
}
if (ready) return@Thread
GearslipLog.w(
"audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " +
"head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
)
configIndex = 0
ready = true
CarAudio.attach(this)
}, "gearslip-audio-config").apply { isDaemon = true }
configTimer = timer
timer.start()
}
fun onMessage(messageId: Int, body: ByteArray) {
@@ -60,9 +101,15 @@ class AudioLink(
if (window != null) maxUnacked = maxOf(1, window)
configIndex = indices.firstOrNull() ?: 0
if (status == STATUS_READY) {
configTimer?.interrupt()
ready = true
GearslipLog.i("audio: ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
GearslipLog.i("audio: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
CarAudio.attach(this)
} else {
GearslipLog.w(
"audio: the sink is not ready (status=$status, 1=WAIT 2=READY) - " +
"nothing will be sent until it is",
)
}
}
@@ -72,17 +119,40 @@ class AudioLink(
lastAckAt = System.currentTimeMillis()
}
else -> GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
// Whatever the head unit answers Setup with, we need its bytes to identify it.
else -> {
GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
GearslipLog.hex(" audio msg", body, limit = 64)
}
}
}
/**
* The head unit's answer to our AudioFocusRequest. Until it grants focus the sink is wired to
* whatever else the car is playing - the radio, usually - and anything we send is discarded,
* so [start] waits here rather than streaming into nothing.
*/
fun onFocus(state: Int) {
GearslipLog.i("audio: <- AudioFocus state=$state (1=GAIN, 2=GAIN_TRANSIENT, 3=LOSS, 4=LOSS_TRANSIENT)")
val name = FOCUS_STATES[state] ?: "unknown($state)"
GearslipLog.i("audio: <- AudioFocusResponse state=$name")
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT
if (focusGranted) startStream() else stop()
}
override fun start() {
if (!ready || started) return
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
if (!focusRequested) {
focusRequested = true
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
GearslipLog.i("audio: -> AudioFocusRequest(GAIN); waiting for the head unit to hand the sink over")
}
// Some head units grant the sink silently and never answer. Do not let that be the end of
// it: start anyway, and let the acks say whether anything is being taken.
startStream()
}
private fun startStream() {
if (!ready || started) return
sendOnChannel(
MSG_START,
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
@@ -90,7 +160,7 @@ class AudioLink(
inFlight = 0
lastAckAt = System.currentTimeMillis()
started = true
GearslipLog.i("audio: -> AudioFocusRequest(GAIN), Start(config=$configIndex)")
GearslipLog.i("audio: -> Start(session=$SESSION_ID, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
}
override fun stop() {
@@ -98,17 +168,26 @@ class AudioLink(
started = false
runCatching {
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
}
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks")
focusRequested = false
focusGranted = false
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
chunks = 0
bytes = 0
}
override fun write(pcm: ByteArray, size: Int) {
if (!started) return
if (inFlight >= maxUnacked) {
// A lost ack must not silence the stream for good; after a stall assume the window cleared.
if (System.currentTimeMillis() - lastAckAt > STALL_MS) inFlight = 0 else return
if (System.currentTimeMillis() - lastAckAt > STALL_MS) {
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window")
inFlight = 0
} else {
dropped++
return
}
}
val timestamp = ByteArray(8)
val micros = System.nanoTime() / 1_000
@@ -116,14 +195,26 @@ class AudioLink(
try {
sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size))
} catch (e: java.io.IOException) {
GearslipLog.e("audio: the link died mid-stream", e)
started = false
return
}
inFlight++
chunks++
bytes += size
// The first chunk proves the whole path; after that, an occasional line is enough to show
// the car is still taking audio and to catch a window that has quietly closed.
if (chunks == 1 || chunks % CHUNK_LOG_EVERY == 0) {
GearslipLog.i(
"audio: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
"dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}",
)
}
}
fun close() {
configTimer?.interrupt()
configTimer = null
stop()
ready = false
CarAudio.detach(this)
@@ -142,7 +233,15 @@ class AudioLink(
private const val STATUS_READY = 2
private const val FOCUS_GAIN = 1
private const val FOCUS_RELEASE = 4
private const val FOCUS_STATE_GAIN = 1
private const val FOCUS_STATE_GAIN_TRANSIENT = 2
private const val SESSION_ID = 2
private const val STALL_MS = 2_000L
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
private const val CONFIG_WAIT_MS = 2_500L
private val FOCUS_STATES = mapOf(
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT",
)
}
}
@@ -6,12 +6,15 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what a
* media app plays, and the car's audio channel, which is where it has to end up.
* Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what the
* phone is playing, and the car's audio channel, which is where it has to end up.
*
* They come and go independently. The phone side needs on-device consent and can be running on
* the bench with no car attached; the car side exists only while a head unit is connected and
* offers a media audio sink. Whichever pair is present, samples flow.
* Capture belongs to the car session, not to any one app. Android grants it through a consent
* dialog that cannot be answered in advance or remembered, so Gearslip asks once, when a head unit
* first offers a sink to play through, and then holds that grant for as long as the car is
* connected. Switching between media apps, or starting playback from the phone, costs nothing
* further: the capture is by audio usage rather than by app, so whatever the phone plays as media
* goes to the car.
*/
object CarAudio {
@@ -36,9 +39,8 @@ object CarAudio {
/** Set by the activity: shows the permission and consent dialogs, which only it can. */
var onConsentRequested: (() -> Unit)? = null
/** The uid being captured (or waited on). */
@Volatile var uid: Int = -1
private set
/** Whether the driver has asked for the car to be fed at all; see CarSettings.pipeAudio. */
@Volatile private var wanted = false
@Volatile private var link: Link? = null
@@ -48,30 +50,48 @@ object CarAudio {
fun attach(link: Link) {
this.link = link
if (_capture.value == Capture.ON) link.start()
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
when {
_capture.value == Capture.ON -> link.start()
wanted -> ask()
}
}
fun detach(link: Link) {
if (this.link === link) this.link = null
}
/** A media app was opened: start hearing it. */
fun request(uid: Int) {
if (this.uid == uid && _capture.value != Capture.OFF && _capture.value != Capture.DENIED) return
if (this.uid != uid) AudioCaptureService.stop()
this.uid = uid
_capture.value = Capture.ASKING
val ask = onConsentRequested
if (ask == null) {
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
_capture.value = Capture.DENIED
} else {
ask()
/**
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
* at most once, and only once there is a sink to play through - asking on the bench, where the
* sound has nowhere to go, would put a system dialog in front of the driver for nothing.
*/
fun request() {
wanted = true
when (_capture.value) {
Capture.ON, Capture.ASKING, Capture.DENIED -> return
Capture.OFF -> if (link == null) {
GearslipLog.i("audio: waiting for the car to offer an audio sink")
} else {
ask()
}
}
}
private fun ask() {
_capture.value = Capture.ASKING
val request = onConsentRequested
if (request == null) {
GearslipLog.w("audio: nothing on the phone can ask for capture consent")
_capture.value = Capture.DENIED
} else {
request()
}
}
/** The car went away. Everything is torn down; the next connection asks again. */
fun release() {
uid = -1
wanted = false
AudioCaptureService.stop()
_capture.value = Capture.OFF
}
@@ -0,0 +1,64 @@
package app.seb3thehacker.gearslip.audio
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.provider.Settings
import app.seb3thehacker.gearslip.GearslipLog
/**
* Keeps the phone's notifications readable while audio is being sent to the car.
*
* Playback capture hangs off a MediaProjection, and since Android 15 any full-screen projection
* makes SystemUI hide notification contents, on the assumption that someone is watching the
* screen. Nobody is: the audio projection never makes a display. The one exemption an ordinary app
* can reach is the developer setting that switches the protection off, so Gearslip turns it off for
* the length of the capture and back on afterwards.
*
* Writing it needs WRITE_SECURE_SETTINGS, granted once over adb. Without the grant this does
* nothing and notifications stay hidden while music plays, which is the platform's default.
*
* Phone mirroring deliberately does not use this: there the screen really is being shown.
*/
internal object ScreenShareGuard {
/** Settings.Global.DISABLE_SCREEN_SHARE_PROTECTIONS_FOR_APPS_AND_NOTIFICATIONS, hidden API. */
private const val SETTING = "disable_screen_share_protections_for_apps_and_notifications"
private const val PREFS = "gearslip_screen_share_guard"
/** Set while Gearslip has the protection switched off, so a crash can be undone at next launch. */
private const val KEY_LIFTED = "lifted"
fun canLift(context: Context): Boolean =
context.checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) ==
PackageManager.PERMISSION_GRANTED
/** Call before the projection starts: the system decides what to hide at that moment. */
fun lift(context: Context) {
if (!canLift(context)) {
GearslipLog.i("audio: notifications will be hidden while capturing (no WRITE_SECURE_SETTINGS)")
return
}
val resolver = context.contentResolver
// Already off, whether by the driver in Developer options or by us: leave it as found.
if (Settings.Global.getInt(resolver, SETTING, 0) != 0) return
runCatching {
prefs(context).edit().putBoolean(KEY_LIFTED, true).commit()
Settings.Global.putInt(resolver, SETTING, 1)
GearslipLog.i("audio: screen share protection off for the drive, notifications stay visible")
}.onFailure { GearslipLog.w("audio: could not lift screen share protection: ${it.message}") }
}
/** Put the protection back, if Gearslip is the one that took it away. */
fun restore(context: Context) {
if (!prefs(context).getBoolean(KEY_LIFTED, false)) return
runCatching {
Settings.Global.putInt(context.contentResolver, SETTING, 0)
prefs(context).edit().remove(KEY_LIFTED).commit()
GearslipLog.i("audio: screen share protection back on")
}.onFailure { GearslipLog.w("audio: could not restore screen share protection: ${it.message}") }
}
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
}
@@ -0,0 +1,135 @@
package app.seb3thehacker.gearslip.call
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.ContactsContract
import android.telecom.TelecomManager
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import app.seb3thehacker.gearslip.GearslipLog
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
data class Caller(val number: String, val name: String?) {
val label: String get() = name ?: number.ifBlank { "Unknown number" }
}
sealed interface CallUiState {
data object None : CallUiState
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
data class Active(val caller: Caller?) : CallUiState
}
/**
* Tracks the phone's call state for the car screen, and answers or declines through the two
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
* and [GearslipCallScreening]'s response to decline one that is still ringing.
*
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
* only ever carries media playback.
*/
object CarCalls {
private val _state = MutableStateFlow<CallUiState>(CallUiState.None)
val state: StateFlow<CallUiState> = _state.asStateFlow()
private var telephony: TelephonyManager? = null
private var watcher: TelephonyCallback? = null
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
private var ringingCaller: Caller? = null
fun start(context: Context) {
if (watcher != null) return
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) = onPhoneState(state)
}
runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
.onSuccess { telephony = manager; watcher = callback }
.onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
}
fun stop() {
val callback = watcher ?: return
runCatching { telephony?.unregisterTelephonyCallback(callback) }
telephony = null
watcher = null
pendingRespond = null
ringingCaller = null
_state.value = CallUiState.None
}
private fun onPhoneState(state: Int) {
when (state) {
TelephonyManager.CALL_STATE_IDLE -> {
pendingRespond = null
ringingCaller = null
_state.value = CallUiState.None
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
pendingRespond = null
_state.value = CallUiState.Active(ringingCaller)
}
TelephonyManager.CALL_STATE_RINGING -> {
// The number itself only ever arrives through the screening service; if this
// fires first, the ringing card appears once incomingCall() catches up.
ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
}
}
}
/** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
ringingCaller = caller
pendingRespond = respond
_state.value = CallUiState.Ringing(caller, canDecline = true)
}
/** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
fun screeningExpired() {
pendingRespond = null
val caller = ringingCaller ?: return
if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
}
fun decline() {
val respond = pendingRespond ?: return
pendingRespond = null
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
}
fun answer(context: Context) {
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
return
}
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
runCatching { telecom.acceptRingingCall() }
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
}
private fun lookUpName(context: Context, number: String): String? {
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
return runCatching {
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
}.getOrNull()
}
}
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
Handler(Looper.getMainLooper()).postDelayed(action, ms)
}
@@ -0,0 +1,53 @@
package app.seb3thehacker.gearslip.call
import android.telecom.Call
import android.telecom.CallScreeningService
import app.seb3thehacker.gearslip.GearslipLog
/**
* Sees every incoming call before it rings, so the car screen can show who is calling and
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
* for, not a privileged permission.
*
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
* response is sent either when the driver taps a button or when that window is about to close,
* whichever comes first. Answering a call that has already passed this point still works, since
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
*/
class GearslipCallScreening : CallScreeningService() {
override fun onScreenCall(callDetails: Call.Details) {
val number = callDetails.handle?.schemeSpecificPart
var responded = false
val respond: (Boolean) -> Unit = { allow ->
if (!responded) {
responded = true
runCatching {
respondToCall(
callDetails,
CallResponse.Builder()
.setDisallowCall(!allow)
.setRejectCall(!allow)
.setSkipNotification(false)
.build(),
)
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
}
}
CarCalls.incomingCall(applicationContext, number, respond)
// Answer for the system if nobody has by the time its own patience runs out, so a call
// Gearslip is slow to show never gets silently blocked.
mainThreadDelay(SCREENING_WINDOW_MS) {
respond(true)
CarCalls.screeningExpired()
}
}
private companion object {
const val SCREENING_WINDOW_MS = 3_500L
}
}
@@ -1,89 +0,0 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
/**
* Draws the raw video frame with pixel rulers, for measuring what the head unit actually shows.
*
* Numbers are frame pixels. Read the last number visible on each edge to see how much the head
* unit crops there (the frame is 800 wide by 480 tall when the ruler runs to those values), and
* tap anywhere: the crosshair is drawn where the phone believes the touch landed, so a mismatch
* with your finger is the touch offset. Ignores vehicle insets on purpose.
*/
@Composable
fun CalibrationApp() {
val navigator = LocalCarNavigator.current
val measurer = rememberTextMeasurer()
var touch by remember { mutableStateOf<Offset?>(null) }
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
touch = down.position
}
},
) {
Canvas(Modifier.fillMaxSize()) {
val label = TextStyle(color = Color.White, fontSize = 11.sp)
val faint = Color(0x55FFFFFF)
val w = size.width
val h = size.height
var y = 0
while (y <= h) {
drawLine(if (y % 100 == 0) Color.White else faint, Offset(0f, y.toFloat()), Offset(w, y.toFloat()), strokeWidth = 1f)
if (y % 20 == 0) {
drawText(measurer, "$y", Offset(2f, y.toFloat()), style = label)
drawText(measurer, "$y", Offset(w - 30f, y.toFloat()), style = label)
}
y += 20
}
var x = 0
while (x <= w) {
drawLine(if (x % 100 == 0) Color.White else faint, Offset(x.toFloat(), 0f), Offset(x.toFloat(), h), strokeWidth = 1f)
if (x % 50 == 0 && x > 0) {
drawText(measurer, "$x", Offset(x.toFloat() + 2f, 2f), style = label)
drawText(measurer, "$x", Offset(x.toFloat() + 2f, h - 46f), style = label)
}
x += 20
}
// Frame border, drawn last and inset by 1px so it is the outermost thing that can show.
drawRect(Color.Red, topLeft = Offset(1f, 1f), size = androidx.compose.ui.geometry.Size(w - 2f, h - 2f), style = androidx.compose.ui.graphics.drawscope.Stroke(2f))
drawText(measurer, "frame ${w.toInt()}x${h.toInt()}", Offset(w / 2 - 50f, h / 2 - 60f), style = TextStyle(color = Color.Yellow, fontSize = 14.sp))
touch?.let { t ->
drawLine(Color.Green, Offset(t.x, 0f), Offset(t.x, h), strokeWidth = 2f)
drawLine(Color.Green, Offset(0f, t.y), Offset(w, t.y), strokeWidth = 2f)
drawText(measurer, "touch ${t.x.toInt()},${t.y.toInt()}", Offset(w / 2 - 50f, h / 2 + 40f), style = TextStyle(color = Color.Green, fontSize = 14.sp))
}
}
Button(onClick = { navigator.home() }, modifier = Modifier.align(Alignment.Center)) {
Text("Exit calibration")
}
}
}
@@ -0,0 +1,132 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.call.CallUiState
import app.seb3thehacker.gearslip.call.CarCalls
/**
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
* whole screen, because nothing else matters more right now; an active call is a thin band along
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
*/
@Composable
fun CallOverlay(modifier: Modifier = Modifier) {
val state by CarCalls.state.collectAsState()
val context = LocalContext.current.applicationContext
when (val s = state) {
// Covers the box regardless of where the caller aligned it, so the alignment below is
// only ever felt by the active-call banner, which is the one that isn't full-screen.
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
is CallUiState.Active -> ActiveCallBanner(s, modifier)
CallUiState.None -> {}
}
}
@Composable
private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline: () -> Unit) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLowest) {
Column(
Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Filled.Call,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
Text(
"Incoming call",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
s.caller.label,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(56.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
}
if (!s.canDecline) {
Spacer(Modifier.height(20.dp))
Text(
"Already ringing out loud - answer here, or let it ring.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
FilledIconButton(
onClick = onClick,
enabled = enabled,
modifier = Modifier.size(72.dp),
colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White),
) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) }
Spacer(Modifier.height(8.dp))
Text(label, style = MaterialTheme.typography.labelLarge)
}
}
@Composable
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = modifier.fillMaxWidth(),
) {
Row(
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.Center,
) {
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
Spacer(Modifier.height(0.dp))
Text(
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
)
}
}
}
@@ -1,7 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Settings
@@ -28,8 +28,8 @@ object CarApps {
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
PhoneAppsScreen()
},
CarApp("calibrate", "Display test", Icons.Filled.Info) {
CalibrationApp()
CarApp("dialer", "Phone", Icons.Filled.Call) {
PhoneDialerScreen()
},
CarApp("settings", "Settings", Icons.Filled.Settings) {
CarSettingsScreen()
@@ -19,7 +19,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.produceState
import androidx.compose.foundation.clickable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -63,25 +66,37 @@ fun CarDashboardScreen() {
@Composable
private fun WeatherCard(context: Context) {
val weather by produceState<Weather?>(Weather.LOADING) {
value = withContext(Dispatchers.IO) { fetchWeather(context) }
}
val state by Weather.state.collectAsState()
val navigator = LocalCarNavigator.current
LaunchedEffect(Unit) { Weather.refresh(context) }
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
Row(Modifier.fillMaxWidth().padding(20.dp), verticalAlignment = Alignment.CenterVertically) {
val w = weather
when {
w === Weather.LOADING -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
w == null -> Text(
"Weather unavailable - no location fix yet.",
style = MaterialTheme.typography.bodyLarge,
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.clickable { navigator.weather() },
) {
Row(
Modifier.fillMaxWidth().padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
when (val s = state) {
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
is WeatherState.Unavailable -> Text(
s.reason, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> Text(
"${w.tempC.toInt()}°C · ${w.description}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
is WeatherState.Ready -> {
val w = s.data
WeatherIcon(w.code, w.isDay, size = 44.dp)
Column {
Text(
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
)
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
@@ -89,8 +104,8 @@ private fun WeatherCard(context: Context) {
@Composable
private fun AgendaCard(context: Context) {
val events by produceState<List<AgendaEvent>?>(null) {
value = withContext(Dispatchers.IO) { fetchAgenda(context) }
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
value = withContext(Dispatchers.IO) { fetchAgenda(context) }.also { cachedAgenda = it }
}
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
@@ -143,63 +158,14 @@ private fun eventWhen(event: AgendaEvent): String {
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
}
private data class Weather(val tempC: Double, val description: String) {
companion object { val LOADING = Weather(Double.NaN, "") }
}
private data class AgendaEvent(val title: String, val startMillis: Long, val allDay: Boolean)
private const val WEATHER_TTL_MS = 60 * 60 * 1000L
private var cachedWeather: Weather? = null
private var cachedWeatherAt = 0L
@Volatile private var cachedAgenda: List<AgendaEvent>? = null
/** Served from memory for up to an hour: weather barely changes, and each call is a network request. */
private fun fetchWeather(context: Context): Weather? {
val now = System.currentTimeMillis()
cachedWeather?.let { if (now - cachedWeatherAt < WEATHER_TTL_MS) return it }
return loadWeather(context)?.also { cachedWeather = it; cachedWeatherAt = now }
}
/** Null means no fix or no permission; the card shows a plain empty state either way. */
private fun loadWeather(context: Context): Weather? {
if (context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
return null
}
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val location = manager.allProviders
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
?: return null
return runCatching {
// Open-Meteo: no API key, no account - the simplest thing that gives a real reading.
val url = URL(
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}" +
"&longitude=${location.longitude}&current_weather=true",
)
val body = (url.openConnection() as HttpURLConnection).run {
connectTimeout = 5_000
readTimeout = 5_000
inputStream.bufferedReader().use { it.readText() }
}
val current = JSONObject(body).getJSONObject("current_weather")
Weather(current.getDouble("temperature"), weatherDescription(current.getInt("weathercode")))
}.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }.getOrNull()
}
/** Open-Meteo's WMO weather codes, collapsed to what's worth a glance from the driver's seat. */
private fun weatherDescription(code: Int): String = when (code) {
0 -> "Clear"
1, 2 -> "Partly cloudy"
3 -> "Overcast"
45, 48 -> "Fog"
in 51..57 -> "Drizzle"
in 61..67 -> "Rain"
in 71..77 -> "Snow"
in 80..82 -> "Rain showers"
in 85..86 -> "Snow showers"
in 95..99 -> "Thunderstorm"
else -> "-"
/** Loads the weather and the agenda ahead of time, so the dashboard opens with both in place. */
fun warmDashboard(context: Context) {
Weather.refresh(context, force = true)
cachedAgenda = fetchAgenda(context)
}
/**
@@ -1,6 +1,5 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,8 +12,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -24,32 +22,26 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import android.media.session.PlaybackState
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.media.CarMedia
import app.seb3thehacker.gearslip.media.MediaArt
import kotlinx.coroutines.delay
import app.seb3thehacker.gearslip.media.NowPlaying
import kotlinx.coroutines.launch
/**
* The home screen: the map on the left, what is playing on the right.
* The home screen: the map on the left, lyrics on the right when asked for.
*
* The map is whatever navigation app was used last, brought back on its own. The player only
* takes its share of the screen while something is loaded; otherwise the map has all of it.
* The map is whatever navigation app was used last, brought back on its own. Playback itself is
* controlled from the nav bar's pill everywhere, including here - this column only opens when the
* pill's lyrics button is on, and closes itself back to a full-width map once nothing is playing.
*/
@Composable
fun CarHome() {
@@ -61,19 +53,20 @@ fun CarHome() {
LaunchedEffect(Unit) { CarServices.autostart(frame) }
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
val showPlayer = phase == CarMedia.Phase.READY && now.hasTrack
val navigator = LocalCarNavigator.current
val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(
Modifier
.weight(if (showPlayer) 2.1f else 1f)
.weight(if (showLyrics) 2.1f else 1f)
.fillMaxHeight()
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
) {
MapPane(navStatus, frame)
}
if (showPlayer) {
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
if (showLyrics) {
LyricsColumn(now, Modifier.weight(1f).fillMaxHeight())
}
}
}
@@ -92,6 +85,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
horizontalAlignment = Alignment.CenterHorizontally,
) {
val hasLast = CarSettings.lastNav.value != null
val scope = androidx.compose.runtime.rememberCoroutineScope()
when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
@@ -101,95 +95,56 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
TextButton(onClick = navigator::apps) { Text("Choose another app") }
}
else -> {
// IDLE with a last app on record means the driver stopped it themselves - it will
// not come back on its own, so the screen must offer a way to bring it back rather
// than sit on a "starting" message that never resolves.
Text(
if (hasLast) "Starting the map…" else "No map app chosen yet.",
if (hasLast) "Map stopped." else "No map app chosen yet.",
style = MaterialTheme.typography.titleMedium,
)
if (!hasLast) {
if (hasLast) {
TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") }
} else {
Text(
"Open Car apps and connect a navigation app; it will start here next time.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
}
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
}
}
}
}
/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */
/**
* Lyrics for whatever the nav bar's pill is playing, beside the map. Transport controls live in
* the pill itself now, not here - this column has exactly one job.
*/
@Composable
private fun MiniPlayer(modifier: Modifier) {
val media = CarServices.media
val now by media.now.collectAsStateWithLifecycle()
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
var position by remember { mutableStateOf(0L) }
LaunchedEffect(now) {
while (true) {
position = now.currentPosition()
delay(500)
}
}
private fun LyricsColumn(now: NowPlaying, modifier: Modifier) {
val navigator = LocalCarNavigator.current
Surface(
onClick = navigator::media,
modifier = modifier,
shape = RoundedCornerShape(EMBEDDED_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(Modifier.size(140.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Spacer(Modifier.height(10.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
now.artist,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (now.durationMs > 0) {
WavyProgress(
fraction = position.toFloat() / now.durationMs,
playing = now.state == PlaybackState.STATE_PLAYING,
onSeek = { media.seek((it * now.durationMs).toLong()) },
modifier = Modifier.padding(vertical = 4.dp),
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
now.title.ifEmpty { "Lyrics" },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.height(16.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { media.previous() }, modifier = Modifier.size(48.dp)) {
Icon(MediaIcons.Previous, "Previous", Modifier.size(30.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(60.dp)) {
Icon(
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
if (now.playing) "Pause" else "Play",
Modifier.size(36.dp),
)
}
IconButton(onClick = { media.next() }, modifier = Modifier.size(48.dp)) {
Icon(MediaIcons.Next, "Next", Modifier.size(30.dp))
IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp))
}
}
Spacer(Modifier.height(4.dp))
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth())
}
}
}
@@ -1,6 +1,9 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@@ -13,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Share
@@ -36,6 +40,17 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap
import android.content.Context
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import app.seb3thehacker.gearslip.host.KnownApps
import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.MediaApp
@@ -48,28 +63,39 @@ private class Tile(
val label: String,
val icon: ImageBitmap? = null,
val glyph: ImageVector? = null,
/** Tested from start to finish with Gearslip; drawn with a check mark. */
val verified: Boolean = false,
/** Known not to work; drawn with a red X instead of being left off the launcher. */
val broken: Boolean = false,
val pinned: Boolean = false,
/** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */
val onLongClick: (() -> Unit)? = null,
val onClick: () -> Unit,
)
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
private class Entry(
internal class Entry(
val label: String,
val icon: ImageBitmap?,
val template: TemplateApp? = null,
val media: MediaApp? = null,
)
) {
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
val componentId: String? get() = (template?.component ?: media?.component)?.flattenToString()
}
/**
* The installed car and media apps with their icons. Scanning the package manager and decoding
* icons is slow, so it happens once per car session; [invalidate] is called when one starts, which
* is the only time new installs are picked up.
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
* which is when new installs are picked up.
*/
private object LauncherCache {
internal object LauncherCache {
@Volatile var entries: List<Entry>? = null
fun invalidate() { entries = null }
fun load(context: Context): List<Entry> {
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
@Synchronized
fun load(context: Context, force: Boolean = false): List<Entry> {
if (!force) entries?.let { return it }
fun iconOf(pkg: String) = runCatching {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull()
@@ -80,8 +106,18 @@ private object LauncherCache {
}
}
/** Forgets the scanned apps so the next launcher open rescans; call at the start of a car session. */
fun invalidateLauncherApps() = LauncherCache.invalidate()
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
/**
* Connects or opens [entry] - what tapping its tile does, shared with the nav bar's pinned
* shortcuts and its open-app icon so there is exactly one place that knows how to launch either
* kind of app.
*/
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
entry.media?.let { CarServices.openMedia(it); navigator.media() }
}
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
@Composable
@@ -90,19 +126,27 @@ fun CarLauncher() {
val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState()
// Scanned once per car session (see LauncherCache), not every time the launcher opens.
// Painted from the cache at once; scanned only if [Prefetch] has not got there first.
val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
val pinned by CarSettings.pinnedApps.collectAsState()
val tiles = listOf(
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") },
) + installed.map { entry ->
Tile(entry.label, entry.icon) {
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
entry.media?.let { CarServices.openMedia(it); navigator.media() }
}
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
val id = entry.componentId
Tile(
entry.label, entry.icon,
verified = pkg != null && KnownApps.works(pkg),
broken = pkg != null && KnownApps.isBroken(pkg),
pinned = id != null && id in pinned,
onLongClick = id?.let { { CarSettings.togglePin(it) } },
) { launchEntry(entry, navigator, frame) }
} + listOf(
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
)
@@ -115,36 +159,94 @@ fun CarLauncher() {
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(tiles) { AppTile(it) }
if (tiles.any { it.verified }) {
item(span = { GridItemSpan(maxLineSpan) }) { VerifiedLegend() }
}
}
}
/** Says what the check mark on a tile means, once, under the grid. */
@Composable
private fun VerifiedLegend() {
Row(
Modifier.fillMaxWidth().padding(top = 4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
VerifiedBadge(16.dp)
Spacer(Modifier.width(8.dp))
Text(
"Tested and working with Gearslip",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** A green disc with a white check: the same mark on every tile and in the legend. */
@Composable
private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFF43A047), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Check, contentDescription = "Tested and working", tint = Color.White, modifier = Modifier.size(size * 0.66f))
}
}
/** A red disc with a white X: marks a tile known not to work with Gearslip yet. */
@Composable
private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f))
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AppTile(tile: Tile) {
val shape = MaterialTheme.shapes.extraLarge
Surface(
onClick = tile.onClick,
modifier = Modifier.height(120.dp),
shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier
.height(120.dp)
.let { if (tile.pinned) it.border(2.dp, MaterialTheme.colorScheme.primary, shape) else it }
// combinedClickable, not Surface's own onClick, so a long press can mean something
// different from a tap - pinning is deliberately the same gesture everywhere apps
// are shown (here and, once pinned, the shortcut itself on the nav bar).
.combinedClickable(onClick = tile.onClick, onLongClick = tile.onLongClick),
shape = shape,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Column(
Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
when {
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
Box(Modifier.fillMaxSize()) {
Column(
Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
when {
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
}
Spacer(Modifier.height(8.dp))
Text(
tile.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (tile.verified) {
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
}
if (tile.broken) {
BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
}
Spacer(Modifier.height(8.dp))
Text(
tile.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
@@ -13,6 +14,9 @@ sealed interface CarScreen {
/** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */
data object Dashboard : CarScreen
/** Opened from the weather chip or the dashboard: now, the next hours and the week. */
data object Weather : CarScreen
/** The current media app, full size: browse, lyrics and controls. */
data object Media : CarScreen
@@ -21,37 +25,81 @@ sealed interface CarScreen {
data class App(val id: String) : CarScreen
}
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
data class Crumb(val screen: CarScreen, val label: String)
/**
* Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
* the on-screen one; an open app can claim it first (a browser going back a page) through
* [backInterceptor].
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
* every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
* or a file manager's breadcrumb bar) rather than replacing the last one outright - that's what
* lets the breadcrumb bar show "Home > Apps > Settings" and jump straight back to any of them,
* and lets [back] undo exactly one step instead of always dumping you back at Home. An open app
* can claim a back step first (a browser going back a page) through [backInterceptor].
*/
class CarNavigator(startId: String) {
var current: CarScreen by mutableStateOf(
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
private val stack = mutableStateListOf(
Crumb(
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
"Home",
),
)
private set
/** The whole trail, root first. Size 1 means "at Home" - the breadcrumb bar hides then. */
val trail: List<Crumb> get() = stack
val current: CarScreen get() = stack.last().screen
var backInterceptor: (() -> Boolean)? = null
fun home() { current = CarScreen.Home }
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
* where you already are shouldn't grow the trail. */
private fun push(screen: CarScreen, label: String) {
if (stack.last().screen == screen) return
stack.add(Crumb(screen, label))
}
fun media() { current = CarScreen.Media }
/** The one way back to a clean slate: resets the whole trail to just Home. */
fun home() {
stack.clear()
stack.add(Crumb(CarScreen.Home, "Home"))
}
fun apps() { current = CarScreen.Apps }
/**
* The nav bar's now-playing pill is the player everywhere; this is only whether the home
* screen is additionally showing lyrics for it in the column beside the map. Toggled by the
* pill's own lyrics button, so it means the same thing wherever the pill is visible, even
* though only the home screen actually has the room to show it.
*/
var lyricsOpen by mutableStateOf(false)
private set
fun settings() { current = CarScreen.Settings }
fun toggleLyrics() { lyricsOpen = !lyricsOpen }
fun dashboard() { current = CarScreen.Dashboard }
fun media() { push(CarScreen.Media, "Media") }
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
fun apps() { push(CarScreen.Apps, "Apps") }
fun open(id: String) { current = CarScreen.App(id) }
fun settings() { push(CarScreen.Settings, "Settings") }
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
fun weather() { push(CarScreen.Weather, "Weather") }
fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") }
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
/** Jumps straight to a step already on the trail, dropping everything after it - what
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
fun jumpTo(index: Int) {
while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
}
/** One step back, same as tapping the crumb before the current one. */
fun back() {
if (backInterceptor?.invoke() == true) return
current = CarScreen.Home
if (stack.size > 1) stack.removeAt(stack.lastIndex)
}
}
@@ -39,6 +39,7 @@ object CarServices {
fun init(appContext: Context) {
context = appContext.applicationContext
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
}
// --- map -----------------------------------------------------------------------------
@@ -54,6 +55,12 @@ object CarServices {
nav.disconnect()
}
/** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
suspend fun reconnectNav(frame: CarEnvironment.Frame) {
navStopped = false
autostart(frame)
}
// --- media ---------------------------------------------------------------------------
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
@@ -62,11 +69,12 @@ object CarServices {
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
if (_mediaApp.value?.component == app.component && live) return
CarAudio.release()
_mediaApp.value = app
autoplayPending = autoplay
media.connect(app)
if (CarSettings.pipeAudio.value) CarAudio.request(app.uid)
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
// switching media apps never costs the driver another consent dialog.
if (CarSettings.pipeAudio.value) CarAudio.request()
}
/** Called once the media app is connected: starts playback if that was asked for. */
@@ -104,6 +112,7 @@ object CarServices {
/** The car went away: nothing should keep running for a screen that no longer exists. */
fun shutdown() {
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
app.seb3thehacker.gearslip.call.CarCalls.stop()
runCatching { nav.disconnect() }
runCatching { media.disconnect() }
CarAudio.release()
@@ -24,6 +24,11 @@ object CarSettings {
private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_THEME = "car_app_theme"
private const val KEY_PINNED = "car_pinned_apps"
/** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to
* still be on the bar, not off the edge of a long list. */
const val MAX_PINNED = 3
/** Id of the app to open on connect; [HOME] opens the launcher. */
const val HOME = "home"
@@ -33,12 +38,16 @@ object CarSettings {
private val scaleFlow = MutableStateFlow(1f)
private val openFlow = MutableStateFlow(HOME)
private val nightFlow = MutableStateFlow(NightMode.AUTO)
private val pipeAudioFlow = MutableStateFlow(false)
private val pipeAudioFlow = MutableStateFlow(true)
private val lastNavFlow = MutableStateFlow<String?>(null)
private val lastMediaFlow = MutableStateFlow<String?>(null)
private val autoplayFlow = MutableStateFlow(false)
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow<AppTheme> = themeFlow
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
/** Flattened component names of the map and media apps used last, brought back on connect. */
val lastNav: StateFlow<String?> = lastNavFlow
@@ -52,9 +61,10 @@ object CarSettings {
val nightMode: StateFlow<NightMode> = nightFlow
/**
* Whether a media app's sound is captured and sent over the car link. Off by default: the
* platform only allows that behind a screen-capture consent dialog, and without it the app
* simply plays through the phone's normal output (e.g. Bluetooth to the car).
* Whether a media app's sound is captured and sent over the car link. On by default, since
* sound in the car is the point of the exercise; the platform allows the capture only behind a
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
* somewhere to play it. Turned off, the app plays through the phone's normal output instead.
*/
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
@@ -68,10 +78,12 @@ object CarSettings {
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
}.getOrDefault(AppTheme.PHONE)
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "false") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
nightFlow.value = runCatching {
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
}.getOrDefault(NightMode.AUTO)
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
.split(",").filter { it.isNotEmpty() }.toSet()
}
fun setScale(value: Float) {
@@ -114,4 +126,17 @@ object CarSettings {
AppSettings.putString(app, KEY_NIGHT, mode.name)
CarEnvironment.refresh()
}
/** Long-pressed onto or off the nav bar. Silently does nothing past [MAX_PINNED] rather than
* bumping the oldest pin - a driver who filled the bar on purpose shouldn't lose one by accident. */
fun togglePin(component: String) {
val current = pinnedFlow.value
val next = when {
component in current -> current - component
current.size >= MAX_PINNED -> return
else -> current + component
}
pinnedFlow.value = next
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
}
}
@@ -98,9 +98,10 @@ fun CarSettingsScreen() {
pipeAudio,
) { CarSettings.setPipeAudio(it) }
Text(
"Phone output needs no permission: the app plays as it normally would, e.g. over " +
"Bluetooth to the car. Through Gearslip sends the sound over the car link, and " +
"Android asks for screen-capture consent each time.",
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
"first time a media app plays in the car, and some apps refuse to be captured at " +
"all. Phone output needs no permission: the app plays as it normally would, for " +
"example over Bluetooth to the car.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -116,9 +117,6 @@ fun CarSettingsScreen() {
Info("Video", display.ifEmpty { "-" })
Info("Certificate", cert?.headline ?: "Checking…")
}
val navigator = LocalCarNavigator.current
Button(onClick = { navigator.open("calibrate") }) { Text("Display test") }
Spacer(Modifier.height(8.dp))
}
}
@@ -163,7 +161,7 @@ private fun WebPageSetting() {
modifier = Modifier.fillMaxWidth(),
) {
Text(
url.ifEmpty { "Built-in test page - tap to set a URL" },
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
style = MaterialTheme.typography.bodyLarge,
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -19,9 +20,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -42,6 +44,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
@@ -53,8 +58,12 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.notify.CarNotifications
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.util.Date
@@ -81,7 +90,14 @@ fun CarUi() {
val base = LocalDensity.current
val insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
remember(appContext) { invalidateLauncherApps(); CarServices.init(appContext) }
val controlsWake = remember { MapControlsWake() }
LaunchedEffect(controlsWake) {
while (true) {
delay(250)
controlsWake.tick()
}
}
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// The map follows the light outside, whatever the app's own theme is set to.
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
@@ -90,9 +106,23 @@ fun CarUi() {
LocalDensity provides Density(base.density * scale, base.fontScale),
LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside,
LocalMapControlsWake provides controlsWake,
) {
MaterialTheme(colorScheme = colors) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Surface(
Modifier
.fillMaxSize()
// Watches every touch without taking any: first in line, consumes nothing.
.pointerInput(controlsWake) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial)
controlsWake.touch()
}
}
},
color = MaterialTheme.colorScheme.background,
) {
// Insets are in video-frame pixels, so convert with the un-scaled density.
val pad = with(base) {
PaddingValues(
@@ -100,29 +130,29 @@ fun CarUi() {
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
)
}
// The calibration screen is the raw frame: no insets, no nav bar.
val screen = navigator.current
if (screen == CarScreen.App("calibrate")) {
CarApps.find("calibrate")?.content?.invoke()
} else {
Column(Modifier.fillMaxSize().padding(pad)) {
Box(Modifier.weight(1f).fillMaxWidth()) {
when (screen) {
CarScreen.Home -> CarHome()
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
}
// The history screen is already showing them, so it needs no popup.
if (screen !is CarScreen.Notifications) {
NotificationPopup(Modifier.align(Alignment.TopCenter))
}
Column(Modifier.fillMaxSize().padding(pad)) {
// Header navigation (breadcrumb trail) - off for now, kept to tweak later.
// BreadcrumbBar(navigator)
Box(Modifier.weight(1f).fillMaxWidth()) {
when (screen) {
CarScreen.Home -> CarHome()
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
}
CarNavBar(navigator)
// The history screen is already showing them, so it needs no popup.
if (screen !is CarScreen.Notifications) {
NotificationPopup(Modifier.align(Alignment.TopCenter))
}
// Above the popup: a call is more urgent than any notification.
CallOverlay(Modifier.align(Alignment.TopCenter))
}
CarNavBar(navigator)
}
}
}
@@ -130,71 +160,189 @@ fun CarUi() {
}
/**
* Home and Apps on the left; now-playing pill in the middle; clock (tap for calendar and weather), phone battery and the
* darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it
* keeps the bar short enough to leave the app underneath more room.
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
* back at all, since a car touchscreen has no hardware button for it.
*/
@Composable
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val darkOutside by CarEnvironment.darkOutside.collectAsState()
val now by rememberNow()
// The home and media screens already show what's playing; everywhere else it lives in the
// middle of this bar so it stays one tap away.
val showNowPlaying = navigator.current != CarScreen.Home && navigator.current != CarScreen.Media
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.weight(1f))
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.clickable { navigator.dashboard() }
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
val light = if (darkOutside) "Night" else "Day"
Text(
listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(10.dp))
NavItem(
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
) { navigator.notifications() }
}
if (showNowPlaying) {
NavNowPlaying(navigator, Modifier.align(Alignment.Center))
private fun BreadcrumbBar(navigator: CarNavigator) {
val trail = navigator.trail
if (trail.size <= 1) return
Row(
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = navigator::back, modifier = Modifier.size(36.dp)) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", Modifier.size(20.dp))
}
Spacer(Modifier.width(2.dp))
trail.forEachIndexed { index, crumb ->
val isLast = index == trail.lastIndex
Text(
crumb.label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = if (isLast) Modifier else Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { navigator.jumpTo(index) }
.padding(horizontal = 4.dp, vertical = 2.dp),
)
if (!isLast) {
Text(
"›",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 2.dp),
)
}
}
}
}
/**
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
* more room.
*/
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
private fun CarNavBar(navigator: CarNavigator) {
val battery by CarEnvironment.battery.collectAsState()
val now by rememberNow()
val weather by Weather.state.collectAsState()
val weatherData = (weather as? WeatherState.Ready)?.data
// The one app that's actually open and not otherwise reachable from this bar: media has its
// own pill already, so the only "open app" icon worth showing is whichever map is connected.
val navStatus by CarServices.nav.status.collectAsState()
val lastNav by CarSettings.lastNav.collectAsState()
val openMapComponent = lastNav?.takeIf { navStatus.phase == CarAppConnection.Phase.RUNNING }
val context = LocalContext.current
val pinnedIds by CarSettings.pinnedApps.collectAsState()
val entries by produceState(LauncherCache.entries ?: emptyList(), context) {
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
}
val pinnedEntries = remember(entries, pinnedIds) {
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
}
val frame by CarEnvironment.frame.collectAsState()
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
// Home and Apps are two different places now, not a toggle on one button - the
// breadcrumb trail is how you retrace your steps, but reaching for either of
// these two from three screens deep in a media app shouldn't mean reading it
// first.
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
openMapComponent?.let { component ->
Spacer(Modifier.width(6.dp))
NavAppIcon(component, contentDescription = "Map", onClick = navigator::home)
}
pinnedEntries.forEach { entry ->
Spacer(Modifier.width(6.dp))
entry.componentId?.let { id ->
NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
}
}
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
}
// Weather no longer has its own chip - the temperature alone (no icon, no room
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
// Home button above.
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
Column(horizontalAlignment = Alignment.End) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
if (battery.percent >= 0) {
Text(
"${battery.percent}%",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (battery.charging) {
ChargingGlyph(Modifier.size(13.dp))
}
}
weatherData?.let { w ->
Text(
" · ${w.temp}°",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(Modifier.width(10.dp))
NavItem(
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp,
) { navigator.notifications() }
}
}
}
}
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
@Composable
private fun NavAppIcon(component: String, contentDescription: String?, onClick: () -> Unit) {
val context = LocalContext.current
val pkg = remember(component) { android.content.ComponentName.unflattenFromString(component)?.packageName }
val icon by produceState<androidx.compose.ui.graphics.ImageBitmap?>(null, pkg) {
value = pkg?.let {
runCatching { context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap() }.getOrNull()
}
}
Box(
Modifier
.size(44.dp)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
}
}
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
@Composable
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
val scheme = MaterialTheme.colorScheme
Box(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(12.dp),
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) { content() }
}
@Composable
private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) {
val scheme = MaterialTheme.colorScheme
Box(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(padding),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
@@ -210,21 +358,25 @@ internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis(
}
}
/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
/**
* The one persistent player, everywhere - not just a Home-screen extra. Art and title open the
* full media screen; skip and play/pause work right here; the last button toggles the lyrics
* panel beside the map on Home (see [CarHome]), the pill's own lyrics affordance wherever it sits.
*/
@Composable
private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.hasTrack) return
if (phase != CarMedia.Phase.READY || !now.isActive) return
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
) {
Row(
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
@@ -233,6 +385,7 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
) {
Row(
Modifier
.weight(1f, fill = false)
.clip(RoundedCornerShape(50))
.clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically,
@@ -242,7 +395,8 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Text(
now.title.ifEmpty { "Nothing playing" }.let { if (it.length > 15) it.take(15) + "…" else it },
now.title.ifEmpty { "Nothing playing" },
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
@@ -256,9 +410,65 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
Modifier.size(26.dp),
)
}
IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
FilledIconButton(
onClick = navigator::toggleLyrics,
modifier = Modifier.size(44.dp),
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
containerColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurfaceVariant,
),
) {
LyricsGlyph(Modifier.size(22.dp))
}
}
}
}
/**
* A card with two lines of text on it - read as "lyrics"/"captions" at a glance, which no icon in
* the core Material set does (the closest, a bullet list, reads as a menu, not song text). Drawn
* rather than a vector asset since the whole glyph is two shapes.
*/
@Composable
private fun LyricsGlyph(modifier: Modifier = Modifier) {
val tint = androidx.compose.material3.LocalContentColor.current
androidx.compose.foundation.Canvas(modifier) {
val stroke = size.minDimension * 0.11f
drawRoundRect(
color = tint,
cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.22f),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
)
val lineStyle = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke * 0.85f, cap = androidx.compose.ui.graphics.StrokeCap.Round)
val startX = size.width * 0.24f
listOf(0.40f, 0.64f).forEach { fy ->
val endX = size.width * (if (fy < 0.5f) 0.76f else 0.62f)
drawLine(tint, androidx.compose.ui.geometry.Offset(startX, size.height * fy), androidx.compose.ui.geometry.Offset(endX, size.height * fy), strokeWidth = lineStyle.width, cap = lineStyle.cap)
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
* bolt, so this is drawn the same way [LyricsGlyph] is.
*/
@Composable
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
androidx.compose.foundation.Canvas(modifier) {
val w = size.width
val h = size.height
val bolt = androidx.compose.ui.graphics.Path().apply {
moveTo(w * 0.58f, 0f)
lineTo(w * 0.12f, h * 0.58f)
lineTo(w * 0.46f, h * 0.58f)
lineTo(w * 0.42f, h)
lineTo(w * 0.88f, h * 0.40f)
lineTo(w * 0.54f, h * 0.40f)
close()
}
drawPath(bolt, tint)
}
}
@@ -0,0 +1,38 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import android.os.SystemClock
/**
* Whether the map's buttons (settings, pan, zoom, locate) are showing.
*
* They fade out after [IDLE_MS] with no touch anywhere on the car screen, to leave the map clear,
* and any touch brings them back. [touch] is called for every pointer event and is cheap: it only
* writes state when the buttons were actually hidden.
*/
class MapControlsWake {
var awake by mutableStateOf(true)
private set
private var lastTouch = SystemClock.uptimeMillis()
fun touch() {
lastTouch = SystemClock.uptimeMillis()
if (!awake) awake = true
}
/** Called on a timer; hides the buttons once nothing has touched the screen for [IDLE_MS]. */
fun tick() {
if (awake && SystemClock.uptimeMillis() - lastTouch >= IDLE_MS) awake = false
}
companion object {
const val IDLE_MS = 4_000L
}
}
val LocalMapControlsWake = staticCompositionLocalOf { MapControlsWake() }
@@ -2,8 +2,13 @@ package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import android.provider.Settings
import android.content.Intent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -60,6 +65,7 @@ import app.seb3thehacker.gearslip.media.LyricsResult
import app.seb3thehacker.gearslip.media.Lyrics
import app.seb3thehacker.gearslip.media.MediaEntry
import app.seb3thehacker.gearslip.media.NowPlaying
import app.seb3thehacker.gearslip.media.QueueTrack
import android.media.session.PlaybackState
/**
@@ -72,9 +78,11 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val media = CarServices.media
val phase by media.phase.collectAsStateWithLifecycle()
val now by media.now.collectAsStateWithLifecycle()
val queue by media.queue.collectAsStateWithLifecycle()
val browse by media.browse.collectAsStateWithLifecycle()
val rejection by media.rejection.collectAsStateWithLifecycle()
val capture by CarAudio.capture.collectAsStateWithLifecycle()
var tab by remember { mutableStateOf(Tab.BROWSE) }
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
// The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) }
@@ -88,28 +96,51 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
}
when (phase) {
CarMedia.Phase.REJECTED -> Notice(
"${app.label} would not let Gearslip browse it.",
"Some media apps only accept Google's own host.",
)
CarMedia.Phase.REJECTED -> when (rejection) {
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
"${app.label} keeps its library to itself.",
"Gearslip can still control it once it has Notification access on the phone. " +
"Allow it under Settings > Notifications > Notification access.",
action = "Open on phone" to {
val app = context.applicationContext
runCatching {
app.startActivity(
Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
},
)
CarMedia.Rejection.REFUSED -> Notice(
"${app.label} would not let Gearslip browse it.",
"Some media apps only accept Google's own host.",
)
}
CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null)
CarMedia.Phase.READY -> Row(
Modifier.fillMaxSize().padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
NowPlayingPanel(now, media, Modifier.width(250.dp).fillMaxHeight())
// The art rail sits at the top, level with the tab row beside it, on every tab -
// not under a full-width tab bar the way it used to be. Transport and the
// progress bar stay on it always; only the app's extra buttons (shuffle, repeat,
// like) are exclusive to the "Now playing" tab's own content area, since those are
// the one thing that isn't already always visible here.
NowPlayingRail(now, media, Modifier.width(220.dp).fillMaxHeight())
Column(Modifier.weight(1f).fillMaxHeight()) {
Row {
TextButton(onClick = { tab = Tab.BROWSE }) {
Text("Browse", fontWeight = if (tab == Tab.BROWSE) FontWeight.Bold else FontWeight.Normal)
}
TextButton(onClick = { tab = Tab.LYRICS }) {
Text("Lyrics", fontWeight = if (tab == Tab.LYRICS) FontWeight.Bold else FontWeight.Normal)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
}
when (tab) {
Tab.BROWSE -> BrowsePanel(browse, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
Spacer(Modifier.height(8.dp))
Box(Modifier.weight(1f).fillMaxWidth()) {
when (tab) {
Tab.NOW_PLAYING -> NowPlayingExtras(now, media, Modifier.fillMaxSize())
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
}
}
}
}
@@ -117,14 +148,30 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
}
}
private enum class Tab { BROWSE, LYRICS }
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
@Composable
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = modifier.height(48.dp),
shape = MaterialTheme.shapes.large,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
/**
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with
* the current line in the accent colour; unsynced ones are plain scrolling text.
*/
@Composable
private fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
value = LyricsState.Loading
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
@@ -189,17 +236,44 @@ private fun AudioBadge(capture: CarAudio.Capture) {
}
@Composable
private fun Notice(title: String, detail: String?) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleMedium)
detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
Column(
Modifier.fillMaxSize().padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(title, style = MaterialTheme.typography.titleMedium, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
detail?.let {
Text(
it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
action?.let { (label, run) ->
Spacer(Modifier.height(12.dp))
androidx.compose.material3.FilledTonalButton(onClick = run) { Text(label) }
}
}
}
/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
private fun formatDuration(ms: Long): String {
val totalSeconds = (ms / 1000).coerceAtLeast(0)
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
}
// --- now playing -----------------------------------------------------------------------------
/**
* The art rail beside every tab: art, title, transport and the seek bar - always visible no
* matter which tab is open. Only the app's extra buttons (shuffle, repeat, like) are held back
* for [NowPlayingExtras], since those are the one thing that isn't already always shown here.
*/
@Composable
private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier) {
private fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) {
val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) {
value = now.art ?: MediaArt.load(context, now.artUri)
@@ -212,55 +286,76 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
}
}
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(170.dp),
) {
art?.let {
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
// Whether the progress bar (and its duration text) is showing depends on whether the app has
// reported a duration yet, which can arrive a beat after everything else - so this rail's
// content height isn't fixed. A plain fillMaxHeight Column just clips whatever doesn't fit
// once that row appears; scrolling means it's always reachable instead.
BoxWithConstraints(modifier) {
// Off the space actually available rather than a fixed dp value, so it doesn't overrun a
// short car frame the way a flat size did on anything shorter than a phone screen.
val artSize = minOf(maxWidth, maxHeight * 0.45f).coerceAtLeast(96.dp)
Column(Modifier.verticalScroll(rememberScrollState())) {
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.size(artSize),
) {
art?.let {
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
}
}
}
Spacer(Modifier.height(8.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2) }
if (now.durationMs > 0) {
WavyProgress(
fraction = position.toFloat() / now.durationMs,
playing = now.state == PlaybackState.STATE_PLAYING,
onSeek = { media.seek((it * now.durationMs).toLong()) },
modifier = Modifier.padding(vertical = 4.dp),
Spacer(Modifier.height(16.dp))
Text(
now.title.ifEmpty { "Nothing playing" },
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
} else {
Spacer(Modifier.height(14.dp))
}
Text(
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
Spacer(Modifier.height(16.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
if (now.durationMs > 0) {
WavyProgress(
fraction = position.toFloat() / now.durationMs,
playing = now.state == PlaybackState.STATE_PLAYING,
onSeek = { media.seek((it * now.durationMs).toLong()) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(Modifier.height(12.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
}
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
}
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
}
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
}
}
}
}
}
// Whatever extra buttons the app publishes (shuffle, repeat, like), by name: their
// icons are resources inside the app's own package.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
/**
* The "Now playing" tab's own content: just the app's extra buttons (shuffle, repeat, like), by
* name - their icons are resources inside the app's own package, so there's nothing to draw but
* the label. Transport and progress already live on [NowPlayingRail], visible on every tab.
*/
@Composable
private fun NowPlayingExtras(now: NowPlaying, media: CarMedia, modifier: Modifier) {
Column(modifier, verticalArrangement = Arrangement.Center) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
now.custom.take(3).forEach { action ->
TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
}
@@ -271,20 +366,31 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
// --- browsing --------------------------------------------------------------------------------
@Composable
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, modifier: Modifier) {
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
Column(modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
// At the library root there's no trail and nowhere to go up to, so there's nothing this
// row could say that the app's name in the top-left corner hasn't already - showing it
// again here (as "Browse", or as the app's own name) was just the same label twice.
if (media.canGoUp || browse.trail.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) {
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
}
browse.trail.lastOrNull()?.let {
Text(
it,
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
Text(
browse.trail.lastOrNull() ?: "Browse",
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
when {
browse.loading -> Notice("Loading…", null)
browse.unavailable -> Notice(
"$appLabel keeps its library to itself.",
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
)
browse.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -294,6 +400,61 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
}
}
// --- up next -----------------------------------------------------------------------------------
/**
* Whatever the app has queued up, starting right after the track that's playing now - not the
* whole queue from the top, which would just repeat what [NowPlayingRail] already shows. Apps
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
*/
@Composable
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
val upNext = remember(queue, now.activeQueueItemId) {
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
if (current >= 0) queue.drop(current + 1) else queue
}
Column(modifier) {
if (upNext.isEmpty()) {
Notice(
"Nothing queued.",
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
"not every app tells Gearslip what comes next.",
)
} else {
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
}
@Composable
private fun QueueRow(track: QueueTrack, media: CarMedia) {
val context = LocalContext.current
val icon by produceState(track.iconBitmap, track.iconUri) {
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
}
Surface(
onClick = { media.playQueueItem(track) },
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (track.subtitle.isNotEmpty()) {
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
val context = LocalContext.current
@@ -48,8 +48,8 @@ import kotlinx.coroutines.withContext
@Composable
fun PhoneAppsScreen() {
val context = LocalContext.current
val apps by produceState(initialValue = emptyList<PhoneApp>(), context) {
value = withContext(Dispatchers.IO) { PhoneApps.installed(context) }
val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
}
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
@@ -0,0 +1,374 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.ContactsContract
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.Button
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** A phone contact with at least one number, as read off the device's contacts. */
private data class Contact(val name: String, val number: String)
private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS)
private fun hasCallPermissions(context: Context): Boolean =
CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
private fun loadContacts(context: Context): List<Contact> {
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList()
val out = mutableListOf<Contact>()
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER,
)
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection, null, null,
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC",
)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (cursor.moveToNext()) {
val name = cursor.getString(nameIndex) ?: continue
val number = cursor.getString(numberIndex) ?: continue
out += Contact(name, number)
}
}
return out.distinctBy { it.number }
}
private fun placeCall(context: Context, number: String) {
if (number.isBlank()) return
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return
runCatching {
context.startActivity(
Intent(Intent.ACTION_CALL, Uri.parse("tel:${Uri.encode(number)}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
private const val TAB_CONTACTS = 0
private const val TAB_KEYPAD = 1
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
@Composable
fun PhoneDialerScreen() {
val context = LocalContext.current
val hasPermissions = hasCallPermissions(context)
var tab by remember { mutableStateOf(TAB_CONTACTS) }
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"Phone",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 4.dp),
)
if (!hasPermissions) {
MissingPermission(context)
return@Column
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.weight(1f)) { tab = TAB_CONTACTS }
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.weight(1f)) { tab = TAB_KEYPAD }
}
Box(Modifier.weight(1f).fillMaxWidth()) {
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
}
}
}
@Composable
private fun MissingPermission(context: Context) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
"from the app's settings.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
FilledTonalButton(onClick = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}) { Text("Open app settings") }
}
}
@Composable
private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = modifier.height(52.dp),
shape = MaterialTheme.shapes.large,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
/**
* A 3-column digit grid beside a fourth column holding backspace and call, stacked - four equal
* columns in all. Every row and both side buttons take an equal weighted share of whatever height
* is available, so this fits the car's short landscape frame without scrolling instead of
* overflowing off the bottom the way fixed-height rows did.
*/
@Composable
private fun Keypad(context: Context) {
var number by remember { mutableStateOf("") }
val rows = listOf(
listOf("1", "2", "3"),
listOf("4", "5", "6"),
listOf("7", "8", "9"),
listOf("*", "0", "#"),
)
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
number.ifEmpty { "Enter a number" },
style = MaterialTheme.typography.headlineSmall,
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(vertical = 6.dp),
)
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(Modifier.weight(3f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEach { row ->
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { digit ->
KeypadButton(digit, Modifier.weight(1f).fillMaxHeight()) { number += digit }
}
}
}
}
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(),
) { Text("⌫") }
FilledIconButton(
onClick = { placeCall(context, number) },
enabled = number.isNotEmpty(),
modifier = Modifier.weight(1f).fillMaxWidth(),
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
),
) { Icon(Icons.Filled.Call, "Call") }
}
}
}
}
@Composable
private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = modifier,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
Text(digit, style = MaterialTheme.typography.titleLarge)
}
}
}
@Composable
private fun ContactsList(context: Context) {
val contacts by produceState(emptyList<Contact>(), context) {
value = withContext(Dispatchers.IO) { loadContacts(context) }
}
var query by remember { mutableStateOf("") }
var searching by remember { mutableStateOf(false) }
var pendingCall by remember { mutableStateOf<Contact?>(null) }
val filtered = remember(contacts, query) {
if (query.isBlank()) contacts
else contacts.filter { it.name.contains(query, ignoreCase = true) || it.number.contains(query) }
}
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (contacts.isNotEmpty()) {
ContactSearchField(query, searching, onToggle = { searching = !searching })
}
// The list (and its empty states) sit between the search field and the keyboard, never
// under either - the keyboard is docked at the bottom instead of jammed in right below
// the field, so results stay visible while typing rather than getting squeezed out.
Box(Modifier.weight(1f).fillMaxWidth()) {
when {
contacts.isEmpty() -> Text(
"No contacts with a phone number found.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
filtered.isEmpty() -> Text(
"No contacts match \"$query\".",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(filtered) { contact ->
ContactRow(contact) { pendingCall = contact }
}
}
}
}
if (searching) {
CarKeyboard(query, onTextChange = { query = it }, onSubmit = { searching = false })
}
}
pendingCall?.let { contact ->
ConfirmCallOverlay(
contact = contact,
onConfirm = { placeCall(context, contact.number); pendingCall = null },
onDismiss = { pendingCall = null },
)
}
}
/**
* A plain Compose overlay, not [androidx.compose.material3.AlertDialog] - that opens a real
* Android Dialog/window, and this UI lives in a Presentation on a virtual display where a second
* window of the wrong type crashes outright ("Window type mismatch"). Every other popup in the
* car UI is drawn in-composition for the same reason; this just follows suit.
*/
@Composable
private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismiss: () -> Unit) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Surface(
onClick = {},
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(0.85f).padding(16.dp),
) {
Column(
Modifier.padding(32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Cancel", style = MaterialTheme.typography.titleMedium)
}
Button(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Call", style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
}
@Composable
private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> Unit) {
Surface(
onClick = onToggle,
shape = MaterialTheme.shapes.large,
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(),
) {
Text(
query.ifEmpty { "Search contacts" },
style = MaterialTheme.typography.bodyLarge,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
}
}
@Composable
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth(),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
Modifier.size(40.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
contact.name.take(1).uppercase(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold,
)
}
Column(Modifier.weight(1f)) {
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary)
}
}
}
@@ -0,0 +1,42 @@
package app.seb3thehacker.gearslip.car
import android.content.Context
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.mirror.PhoneApps
import java.util.concurrent.Executors
/**
* Loads what the car screens show before anyone opens them: the car and media apps with their
* icons, the phone's launcher apps, the weather and the calendar. Each screen paints from these
* results at once and refreshes behind them, so nothing waits on the package manager, the
* network or the calendar provider.
*
* Safe to call as often as you like: calls closer together than [MIN_GAP_MS] are dropped.
*/
object Prefetch {
private const val MIN_GAP_MS = 60_000L
private val worker = Executors.newSingleThreadExecutor { Thread(it, "gearslip-prefetch").apply { isDaemon = true } }
@Volatile private var lastRun = 0L
/** [force] skips the throttle; use it when something changed, such as a permission grant. */
fun warm(context: Context, force: Boolean = false) {
val now = System.currentTimeMillis()
if (!force && now - lastRun < MIN_GAP_MS) return
lastRun = now
val app = context.applicationContext
worker.execute {
val start = System.currentTimeMillis()
step("launcher apps") { warmLauncherApps(app) }
step("phone apps") { PhoneApps.warm(app) }
step("weather and calendar") { warmDashboard(app) }
GearslipLog.i("prefetch done in ${System.currentTimeMillis() - start}ms")
}
}
// One failing loader must not stop the others.
private fun step(name: String, block: () -> Unit) {
runCatching(block).onFailure { GearslipLog.w("prefetch: $name failed: ${it.message}") }
}
}
@@ -1,6 +1,9 @@
package app.seb3thehacker.gearslip.car
import android.graphics.Rect
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -10,28 +13,46 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Template
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.car.app.navigation.model.MapTemplate
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.color
import app.seb3thehacker.gearslip.host.image
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime
import app.seb3thehacker.gearslip.host.text
@@ -82,17 +103,18 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (t
@Composable
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
val pan by CarServices.nav.panMode.collectAsState()
Box(modifier.fillMaxWidth()) {
(template.navigationInfo as? RoutingInfo)?.let {
if (!pan) (template.navigationInfo as? RoutingInfo)?.let {
RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
}
RightEdgeControls(
template.actionStrip,
if (pan) null else template.actionStrip,
template.mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
template.destinationTravelEstimate?.let {
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
if (!pan) template.destinationTravelEstimate?.let {
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
}
}
}
@@ -102,7 +124,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) {
ChromeSurface(modifier) {
Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
}
return
}
@@ -116,89 +138,174 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
Column(Modifier.weight(1f)) {
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Text(distance, style = ChromeType.headline)
}
Text(
step.cue.text(),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
val road = step.road.text()
if (road.isNotEmpty()) {
Text(
road,
style = MaterialTheme.typography.bodyMedium,
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Neither is sent by every app - most say only "turn left" and trust the driver to
// find the lane themselves - so both are drawn only when there is something to draw.
info.junctionImage?.let { junction ->
Spacer(Modifier.height(10.dp))
JunctionImage(junction)
}
LaneGuidance(step, Modifier.padding(top = 10.dp))
info.nextStep?.let { next ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"then",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(8.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(
next.cue.text(),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/** Time and distance left, along the bottom where it stays out of the map's way. */
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
@Composable
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
val bitmap = icon.image(LocalContext.current) ?: return
Image(
bitmap,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier.fillMaxWidth().heightIn(max = 120.dp),
)
}
/**
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
* everything else uses, never dimmed by transparency.
*/
@Composable
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
step.lanesImage?.let {
JunctionImage(it, modifier)
return
}
val lanes = step.lanes.orEmpty()
if (lanes.isEmpty()) return
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
lanes.forEach { lane -> LaneTile(lane) }
}
}
@Composable
private fun LaneTile(lane: Lane) {
val directions = lane.directions.orEmpty()
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
val recommended = shown?.isRecommended == true
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (recommended) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHighest,
),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = if (recommended) "Recommended lane" else "Lane",
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
)
}
}
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
else -> 0f
}
/**
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
*/
@Composable
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display()
val arrival = estimate.arrivalTimeAtDestination.clockTime()
val trip = estimate.tripText.text()
if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
Surface(
color = Color.Black.copy(alpha = 0.6f),
contentColor = Color.White,
modifier = modifier.fillMaxWidth(),
) {
val detail = listOfNotNull(
arrival.takeIf { it.isNotEmpty() },
distance.takeIf { it.isNotEmpty() },
trip.takeIf { it.isNotEmpty() },
).joinToString(" • ")
// The same solid card the manoeuvre card above it uses - one surface for the whole map
// chrome, rather than this one spot styled on its own as a bar over the map.
ChromeSurface(modifier, shadowElevation = 8.dp) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
Row(
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
if (time.isNotEmpty()) {
Text(
time,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = estimate.remainingTimeColor.color(dark, Color.White),
Box(
Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
// Not a disconnect: the connection stays up and the app decides its own next
// screen. Ending navigation is its own call, separate from a back press - a
// back press only pops the on-screen card, leaving the app still believing it
// is navigating (so a freshly picked destination silently does nothing); this
// is the call that actually clears that flag on the app's side.
.clickable { CarServices.nav.stopNavigating() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.Close,
contentDescription = "Stop navigating",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp),
)
}
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.titleMedium,
color = estimate.remainingDistanceColor.color(dark, Color.White),
)
}
if (trip.isNotEmpty()) {
Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
Spacer(Modifier.width(10.dp))
Column {
if (time.isNotEmpty()) {
Text(
time,
style = ChromeType.headline,
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
)
}
if (detail.isNotEmpty()) {
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@@ -207,25 +314,39 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
// --- map with a side pane ----------------------------------------------------------------------
/**
* The shape every remaining map-backed template shares: a pane down the left, the app's map
* showing through on the right, and both action strips stacked at the right edge.
* The shape every remaining map-backed template shares: the app's map across the whole surface,
* the template's content in a card floating over its left side, and both action strips stacked at
* the right edge. The card is as tall as its content, up to the height of the screen, so a
* handful of buttons no longer claim a whole column of the map. [fullHeight] is for content that
* needs the room, such as a search field with its keyboard.
*/
@Composable
private fun MapPaneLayout(
modifier: Modifier,
actionStrip: androidx.car.app.model.ActionStrip?,
mapActionStrip: androidx.car.app.model.ActionStrip?,
pane: @Composable (Modifier) -> Unit,
fullHeight: Boolean = false,
pane: @Composable () -> Unit,
) {
Box(modifier.fillMaxWidth()) {
pane(
Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.fillMaxWidth(CONTENT_PANE_FRACTION),
)
// Pan mode hands the whole screen to the map, leaving only its own controls.
val pan by CarServices.nav.panMode.collectAsState()
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
if (!pan) {
ChromeSurface(
Modifier
.align(Alignment.TopStart)
.padding(12.dp)
.width(maxOf(maxWidth * CONTENT_PANE_FRACTION - 12.dp, 232.dp))
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
shape = androidx.compose.foundation.shape.RoundedCornerShape(20.dp),
shadowElevation = 8.dp,
) {
androidx.compose.runtime.CompositionLocalProvider(LocalMapEdgeActions provides edgeActions) { pane() }
}
}
RightEdgeControls(
actionStrip,
if (pan) null else actionStrip,
mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp),
)
@@ -238,8 +359,12 @@ private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Mod
modifier,
template.actionStrip,
template.mapController?.mapActionStrip,
) { paneModifier ->
ContentTemplate(template.contentTemplate, paneModifier)
fullHeight = template.contentTemplate.needsFullHeight(),
) {
// A pane floating over the map is a place or a route the app is proposing, so its one
// action - Start - counts itself down rather than waiting to be pressed; the same pane
// filling a whole screen elsewhere gets no such push.
ContentTemplate(template.contentTemplate, Modifier, autoStartPane = true)
}
}
@@ -249,15 +374,15 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
modifier,
template.actionStrip,
template.mapController?.mapActionStrip,
) { paneModifier ->
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
) {
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(
headerTitle(template.header, ""),
template.header?.startHeaderAction,
template.header?.endHeaderActions.orEmpty(),
)
if (template.pane != null) PaneRows(template.pane)
if (template.pane != null) PaneRows(template.pane, autoStart = true)
else ItemListColumn(template.itemList)
}
}
@@ -266,26 +391,26 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
@Composable
private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
ListPane(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.isLoading,
template.itemList,
paneModifier,
Modifier,
)
}
}
@Composable
private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, null) { paneModifier ->
MapPaneLayout(modifier, template.actionStrip, null) {
ListPane(
template.title.text(),
template.headerAction,
template.isLoading,
template.itemList,
paneModifier,
Modifier,
)
}
}
@@ -293,23 +418,23 @@ private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifie
/** Route choices down the side, with the app's "start" action pinned under them. */
@Composable
private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier ->
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column(Modifier.fillMaxHeight()) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
)
Box(Modifier.weight(1f)) {
Box(Modifier.weight(1f, fill = false)) {
if (template.isLoading) {
Text("Finding routes…", Modifier.padding(14.dp))
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
} else {
ItemListColumn(template.itemList)
}
}
template.navigateAction?.let {
ActionRow(listOf(it), Modifier.padding(12.dp))
AutoStartButton(it, Modifier.padding(12.dp))
}
}
}
@@ -327,7 +452,7 @@ private fun ListPane(
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column {
HeaderBar(title, startAction)
if (loading) Text("Loading…", Modifier.padding(14.dp))
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
else ItemListColumn(itemList)
}
}
@@ -338,3 +463,8 @@ private const val CONTENT_PANE_FRACTION = 0.42f
/** Fraction of the surface the travel-estimate bar covers along the bottom. */
private const val ESTIMATE_FRACTION = 0.14f
/** Search, sign-in and the like are laid out for a whole column, so they keep one. */
private fun Template?.needsFullHeight() =
this is androidx.car.app.model.SearchTemplate || this is androidx.car.app.model.signin.SignInTemplate ||
this is androidx.car.app.model.TabTemplate || this is androidx.car.app.model.LongMessageTemplate
@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.car.app.model.GridTemplate
@@ -51,6 +52,7 @@ import androidx.car.app.media.model.MediaPlaybackTemplate
import androidx.car.app.OnDoneCallback
import androidx.car.app.serialization.Bundleable
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.host.isDrawable
import app.seb3thehacker.gearslip.host.inputChanged
import app.seb3thehacker.gearslip.host.inputSubmitted
import app.seb3thehacker.gearslip.host.submitted
@@ -65,12 +67,12 @@ import app.seb3thehacker.gearslip.host.textChanged
* the same function either way - the only difference is how much room they get.
*/
@Composable
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) {
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStartPane: Boolean = false) {
when (template) {
null -> Unit
is ListTemplate -> ListContent(template, modifier)
is GridTemplate -> GridContent(template, modifier)
is PaneTemplate -> PaneContent(template, modifier)
is PaneTemplate -> PaneContent(template, modifier, autoStartPane)
is MessageTemplate -> MessageContent(template, modifier)
is LongMessageTemplate -> LongMessageContent(template, modifier)
is SearchTemplate -> SearchContent(template, modifier)
@@ -103,7 +105,10 @@ private fun ListContent(template: ListTemplate, modifier: Modifier) {
template.actionStrip,
)
if (template.isLoading) Loading()
else ItemListColumn(template.singleList, template.sectionedLists?.map { it.itemList })
else ItemListColumn(
template.singleList, template.sectionedLists?.map { it.itemList },
Modifier.weight(1f, fill = false),
)
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@@ -117,13 +122,13 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.isLoading) Loading() else GridItems(template.singleList)
if (template.isLoading) Loading() else GridItems(template.singleList, Modifier.weight(1f, fill = false))
ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
}
}
@Composable
private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
private fun PaneContent(template: PaneTemplate, modifier: Modifier, autoStart: Boolean = false) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
@@ -131,7 +136,7 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane)
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane, autoStart = autoStart)
}
}
@@ -140,27 +145,45 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
@Composable
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
ContentSurface(modifier) {
HeaderBar(
headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(),
template.actionStrip,
)
// The header carries only the back arrow, not a title - a message template's own title
// is usually the long-form question ("Would you like to download the map...") that
// reads fine centred in the body but doesn't fit a one-line header without truncating.
val backAction = template.header?.startHeaderAction ?: template.headerAction
HeaderBar("", backAction, template.header?.endHeaderActions.orEmpty(), template.actionStrip)
if (template.isLoading) {
Loading()
return@ContentSurface
}
Row(
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
// A back arrow already undoes this screen, so an app's own "Cancel" button would just
// be a second way to do the one thing the header already offers.
val actions = template.actions.orEmpty().filter { it.isDrawable() }.let { list ->
if (backAction != null) list.filterNot { it.title.text().equals("Cancel", ignoreCase = true) } else list
}
Column(
Modifier.weight(1f).fillMaxWidth().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
template.icon?.let {
CarGlyph(it, Modifier.size(32.dp))
Spacer(Modifier.width(12.dp))
CarGlyph(it, Modifier.size(40.dp))
Spacer(Modifier.height(12.dp))
}
Text(template.title.text(), style = ChromeType.title, textAlign = TextAlign.Center)
val detail = template.message.text()
if (detail.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
detail,
style = ChromeType.body,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (actions.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
ActionRow(actions, Modifier.fillMaxWidth(0.6f), large = true)
}
Text(template.message.text(), style = MaterialTheme.typography.bodyLarge)
}
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
}
}
@@ -178,7 +201,7 @@ private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier
)
Text(
template.message.text(),
style = MaterialTheme.typography.bodyMedium,
style = ChromeType.body,
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
@@ -242,7 +265,7 @@ private fun SearchField(query: String, hint: String, modifier: Modifier = Modifi
) {
Text(
query.ifEmpty { hint.ifEmpty { "Search" } },
style = MaterialTheme.typography.titleMedium,
style = ChromeType.body,
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
@@ -276,7 +299,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
if (instructions.isNotEmpty()) {
Text(
instructions,
style = MaterialTheme.typography.bodyLarge,
style = ChromeType.body,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
)
}
@@ -295,7 +318,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
if (additional.isNotEmpty()) {
Text(
additional,
style = MaterialTheme.typography.bodySmall,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
@@ -316,7 +339,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
if (error.isNotEmpty()) {
Text(
error,
style = MaterialTheme.typography.bodySmall,
style = ChromeType.label,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp),
)
@@ -340,8 +363,7 @@ private fun Callout(value: String, modifier: Modifier) {
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
style = ChromeType.headline,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
@@ -382,7 +404,7 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) {
CarGlyph(it, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
}
Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1)
Text(tab.title.text(), style = ChromeType.label, maxLines = 1)
}
}
}
@@ -447,7 +469,7 @@ private fun SectionRows(section: Section<*>) {
if (items.isEmpty()) {
val message = section.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
items.filterIsInstance<CarRow>().forEach { RowItem(it) }
@@ -471,7 +493,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
style = MaterialTheme.typography.bodyMedium,
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
@@ -484,7 +506,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
@Composable
private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) {
ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) {
Column(Modifier.fillMaxSize(), content = content)
Column(Modifier.fillMaxWidth(), content = content)
}
}
@@ -493,6 +515,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
@Composable
private fun Loading() {
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
Text("Loading…", style = MaterialTheme.typography.bodyLarge)
Text("Loading…", style = ChromeType.title)
}
}
@@ -4,12 +4,14 @@ import androidx.compose.runtime.remember
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -30,6 +32,21 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import app.seb3thehacker.gearslip.host.selected
import app.seb3thehacker.gearslip.host.checkedChanged
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.draw.alpha
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -67,17 +84,21 @@ import app.seb3thehacker.gearslip.host.text
/**
* The app keeps drawing its map across the whole surface - the visible area we report tells it
* where to put its own markers, not where to stop painting. So anything the host lays over the
* map is opaque by default, or the map reads through it.
* map is opaque, always, in the same one colour: a card that lets the map show through in places
* reads as broken glass, not as a deliberate choice, and a driver has no way to tell which was
* meant. There is no alpha knob here on purpose - every card, bar and strip in the template
* chrome shares this surface, so nothing drawn over a map is ever see-through.
*/
@Composable
internal fun ChromeSurface(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp),
alpha: Float = 1f,
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
content: @Composable () -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
shadowElevation = shadowElevation,
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = shape,
modifier = modifier,
@@ -85,6 +106,32 @@ internal fun ChromeSurface(
)
}
/**
* The one type scale for everything a templated app's screen draws. Apps hand the host plain
* strings with no size of their own - the Car App Library deliberately keeps that decision out of
* their hands - so without a shared scale, each template's text ends up whatever size felt right
* when that template was written, and two apps' otherwise-identical rows read as different sizes
* of importance. Every car chrome file should reach for one of these four rather than naming a
* Material size directly.
*/
internal object ChromeType {
/** The one number or word a card exists to show: distance to a turn, minutes left, a PIN. */
val headline: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)
/** A row's name, a header, a manoeuvre's instruction - the line the rest is supporting. */
val title: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
/** Supporting detail: a road, an address, a message's body, a second line under a title. */
val body: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.titleMedium
/** The smallest text Gearslip draws over a map - still large enough to read while moving. */
val label: androidx.compose.ui.text.TextStyle
@Composable get() = MaterialTheme.typography.bodyLarge
}
@Composable
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
@@ -92,7 +139,7 @@ internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier
Text(
"${template.javaClass.simpleName} isn't drawn yet",
Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium,
style = ChromeType.title,
)
}
}
@@ -109,7 +156,12 @@ internal fun HeaderBar(
endActions: List<Action> = emptyList(),
actionStrip: ActionStrip? = null,
) {
val end = (endActions + actionStrip?.actions.orEmpty()).filter { it.isDrawable() }
// Beside a map the action strip already sits at the right edge, so the header must not repeat it;
// and an app that lists the same action in two places should still see it once.
val onMap = LocalMapEdgeActions.current
val end = (endActions + if (onMap != null) emptyList() else actionStrip?.actions.orEmpty())
.filter { it.isDrawable() && (onMap == null || it.identity() !in onMap) }
.distinctBy { it.identity() }
if (title.isEmpty() && startAction == null && end.isEmpty()) return
Row(
@@ -120,8 +172,8 @@ internal fun HeaderBar(
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
style = ChromeType.title,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
@@ -130,6 +182,22 @@ internal fun HeaderBar(
}
}
/**
* Set inside the card beside a map to what the map's right edge already shows: the action strip is
* drawn there rather than in the header, and the header leaves out anything the edge repeats.
* Null anywhere else.
*/
internal val LocalMapEdgeActions = androidx.compose.runtime.compositionLocalOf<Set<String>?> { null }
/** What makes two actions the same button: what they do and what they look like. */
internal fun Action.identity(): String {
val icon = icon?.icon
val image = if (icon != null && icon.type == androidx.core.graphics.drawable.IconCompat.TYPE_RESOURCE) {
"${icon.resPackage}:${icon.resId}"
} else icon?.toString().orEmpty()
return "$type|${title.text()}|$image"
}
/** Reads a header whichever way the template carries it. */
internal fun headerTitle(header: Header?, fallback: String): String =
header?.title.text().ifEmpty { fallback }
@@ -150,8 +218,35 @@ internal fun RightEdgeControls(
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
if (actions.isEmpty() && mapActions.isEmpty()) return
// A template that stops offering Pan must not leave the screen stuck in pan mode.
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) CarServices.nav.setPanMode(false) }
Column(modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
// the first touch on one wakes them rather than pressing it.
val awake = LocalMapControlsWake.current.awake
val pan by CarServices.nav.panMode.collectAsState()
val visible = awake || pan
val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
label = "mapControls",
)
Column(
modifier
.alpha(alpha)
.let { base ->
if (visible) base else base.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
}
},
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (actions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEach { ActionButton(it) }
@@ -161,43 +256,169 @@ internal fun RightEdgeControls(
}
}
/** [large] is the pane's main call to action: it shares the card's full width, taller, in bigger type. */
@Composable
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier) {
internal fun ActionRow(actions: List<Action>, modifier: Modifier = Modifier, large: Boolean = false) {
val drawable = actions.filter { it.isDrawable() }
if (drawable.isEmpty()) return
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
drawable.forEach { ActionButton(it) }
Row(if (large) modifier.fillMaxWidth() else modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
drawable.forEach { ActionButton(it, large = large, modifier = if (large) Modifier.weight(1f) else Modifier) }
}
}
/** A row with nothing to show: no words, no picture, nothing to tap. */
private fun CarRow.isBlank(): Boolean =
title.text().isBlank() && texts.orEmpty().all { it.text().isBlank() } && image == null &&
toggle == null && onClickDelegate == null && actions.orEmpty().isEmpty()
/**
* One look everywhere: solid, in the theme's own colors, whether the button sits on a card or over
* the map. An app that names a color for its button (a green Start) keeps it.
*/
@Composable
internal fun ActionButton(action: Action) {
internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modifier = Modifier) {
if (action.type == Action.TYPE_APP_ICON) {
AppIconBadge()
return
}
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text()
val background = action.backgroundColor.color(dark, Color.Black.copy(alpha = 0.6f))
val panOn by CarServices.nav.panMode.collectAsState()
val active = action.type == Action.TYPE_PAN && panOn
val scheme = MaterialTheme.colorScheme
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
val fallback = scheme.surfaceContainerHighest
val background = if (active) scheme.primary else action.backgroundColor.color(dark, fallback)
val content = when {
active -> scheme.onPrimary
custom -> Color.White
else -> scheme.onSurface
}
val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
Surface(
color = background,
contentColor = Color.White,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.clickable(enabled = action.isEnabled) {
action.onClickDelegate.click("action")
contentColor = content,
shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
modifier = modifier.clickable(enabled = action.isEnabled) {
if (action.type == Action.TYPE_BACK) CarServices.nav.backPressed()
else if (action.type == Action.TYPE_PAN) CarServices.nav.setPanMode(!panOn)
else action.onClickDelegate?.click("action")
},
) {
Row(
Modifier.padding(horizontal = if (title.isEmpty()) 10.dp else 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
if (title.isNotEmpty()) {
if (large && title.isNotEmpty()) {
// The label sits dead centre on the button; the icon hangs off the left edge, so the
// pair's uneven visual weight can't pull the words off to one side.
androidx.compose.foundation.layout.Box(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 18.dp),
contentAlignment = Alignment.Center,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
Text(title, style = ChromeType.title, maxLines = 1)
}
} else if (title.isEmpty()) {
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
androidx.compose.foundation.layout.Box(Modifier.size(ICON_BUTTON_SIZE), contentAlignment = Alignment.Center) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(ICON_BUTTON_GLYPH), standardFor(action))
}
} else {
Row(
Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
if (hasGlyph) Spacer(Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.labelLarge, maxLines = 1)
Text(title, style = ChromeType.body, maxLines = 1)
}
}
}
}
/**
* A large action that fires on its own after five seconds, filling to show the countdown - for
* the one button on a map-preview pane, where the app is proposing a single place or route and a
* driver glancing at the car screen has already decided. Tapping it, same as any other button,
* fires it at once; the fill is only ever a preview of what a touch already does.
*/
@Composable
internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
var fraction by remember(action.identity()) { mutableStateOf(0f) }
var fired by remember(action.identity()) { mutableStateOf(false) }
fun fire() {
if (fired) return
fired = true
action.onClickDelegate.click("auto-start")
}
androidx.compose.runtime.LaunchedEffect(action.identity()) {
val steps = 60
repeat(steps) { step ->
kotlinx.coroutines.delay(AUTO_START_MS / steps)
fraction = (step + 1) / steps.toFloat()
}
fire()
}
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
val background = action.backgroundColor.color(dark, MaterialTheme.colorScheme.primary)
val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
val hasGlyph = action.icon != null
Surface(
// An unfilled track, solid, not the button's own colour dimmed with alpha - so the map
// behind never shows through however much of the five seconds is left.
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = content,
shape = RoundedCornerShape(28.dp),
modifier = modifier.fillMaxWidth().clickable(enabled = action.isEnabled) { fire() },
) {
androidx.compose.foundation.layout.Box {
// The countdown itself: a plain fill sweeping left to right underneath the label.
// matchParentSize(), not fillMaxHeight() - this Box sits beside a label Box that
// wraps its own content, and the outer Box sizes itself to its tallest child; a
// plain fillMaxHeight() here has nothing bounded to fill up to but the whole screen,
// which is what was stretching the whole button that tall. matchParentSize() instead
// takes whatever size the label ends up being, without itself voting on what that is.
androidx.compose.foundation.layout.Box(Modifier.matchParentSize()) {
androidx.compose.foundation.layout.Box(
Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.background(background),
)
}
androidx.compose.foundation.layout.Box(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
Text(
action.title.text().ifEmpty { "Start" },
style = ChromeType.title,
maxLines = 1,
)
}
}
}
}
private const val AUTO_START_MS = 5_000L
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
@Composable
private fun AppIconBadge() {
val context = LocalContext.current
val pkg = CarServices.nav.appPackage
val icon = remember(pkg) {
pkg?.let {
runCatching {
context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap()
}.getOrNull()
}
} ?: return
Image(icon, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)))
}
/** A standard Action carries no icon of its own - the host is expected to supply the glyph. */
private fun standardFor(action: Action): Int? = when (action.type) {
Action.TYPE_BACK -> CarIcon.TYPE_BACK
@@ -249,8 +470,7 @@ internal fun PaneTitle(title: String) {
if (title.isEmpty()) return
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
style = ChromeType.title,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
)
}
@@ -267,11 +487,16 @@ internal fun ItemListColumn(
}
if (rows.isEmpty()) {
val message = single?.noItemsMessage.text()
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
return
}
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
val onSelected = single?.onSelectedDelegate
val chosen = single?.selectedIndex ?: -1
LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) {
items(rows) { RowItem(it) }
itemsIndexed(rows) { index, row ->
RowItem(row, onSelected?.let { RowSelection(index == chosen) { it.selected(index) } })
}
}
}
@@ -281,9 +506,9 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return
LazyVerticalGrid(
columns = GridCells.Adaptive(96.dp),
columns = GridCells.Adaptive(84.dp),
modifier = modifier,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
@@ -298,7 +523,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
Spacer(Modifier.height(6.dp))
Text(
item.title.text(),
style = MaterialTheme.typography.labelLarge,
style = ChromeType.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -306,7 +531,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
if (subtitle.isNotEmpty()) {
Text(
subtitle,
style = MaterialTheme.typography.labelSmall,
style = ChromeType.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -317,53 +542,91 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
}
}
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
*/
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
val rows = pane?.rows.orEmpty()
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
Column(modifier) {
rows.forEach { RowItem(it) }
ActionRow(
pane?.actions.orEmpty(),
Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
rows.forEach { RowItem(it, large = true) }
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
}
}
@Composable
internal fun RowItem(row: CarRow) {
val clickable = row.onClickDelegate != null && row.isEnabled
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle
// Shown at once and corrected when the app answers with its own template, so the switch
// never lags a tap; keyed on what the app last said so its answer wins.
var checked by remember(toggle?.isChecked) { mutableStateOf(toggle?.isChecked == true) }
val clickable = row.isEnabled && (toggle != null || selection != null || row.onClickDelegate != null)
Row(
Modifier
.fillMaxWidth()
.let { if (clickable) it.clickable { row.onClickDelegate.click("row") } else it }
.let {
if (!clickable) it else it.clickable {
when {
toggle != null -> {
checked = !checked
toggle.onCheckedChangeDelegate.checkedChanged(checked)
}
selection != null -> selection.onPick()
else -> row.onClickDelegate.click("row")
}
}
}
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let {
CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp))
}
// large only changes how much room a row gets (more lines, a bigger icon above) - the
// type itself is the same scale as every other row, so a pane's one row and a plain
// list's tenth row read as the same kind of text.
Column(Modifier.weight(1f)) {
Text(
row.title.text(),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
style = ChromeType.title,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
row.texts.orEmpty().take(2).forEach { line ->
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text(
line.text(),
style = MaterialTheme.typography.bodySmall,
style = ChromeType.body,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis,
)
}
}
ActionRow(row.actions.orEmpty())
if (toggle != null) {
Spacer(Modifier.width(8.dp))
Switch(checked = checked, onCheckedChange = null, enabled = row.isEnabled)
}
}
}
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
@@ -381,3 +644,6 @@ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
}
return seen > 0
}
private val ICON_BUTTON_SIZE = 54.dp
private val ICON_BUTTON_GLYPH = 30.dp
@@ -0,0 +1,213 @@
package app.seb3thehacker.gearslip.car
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import app.seb3thehacker.gearslip.GearslipLog
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONObject
class HourForecast(val time: Long, val temp: Int, val code: Int, val isDay: Boolean, val rainChance: Int)
class DayForecast(val date: Long, val high: Int, val low: Int, val code: Int, val rainChance: Int)
class WeatherData(
val place: String,
val temp: Int,
val feelsLike: Int,
val code: Int,
val isDay: Boolean,
val humidity: Int,
val wind: Int,
val tempUnit: String,
val windUnit: String,
val hours: List<HourForecast>,
val days: List<DayForecast>,
val fetchedAt: Long,
)
sealed interface WeatherState {
data object Loading : WeatherState
data class Unavailable(val reason: String) : WeatherState
data class Ready(val data: WeatherData) : WeatherState
}
/**
* Current weather and a week's forecast for wherever the phone is, from Open-Meteo (free, no
* key or account; the data is CC BY 4.0, so the weather screen credits it).
*
* The location is the phone's last fix if it is recent, otherwise a fresh one is requested, so
* a phone that has been idle does not leave the card on "unavailable".
*/
object Weather {
private const val TTL_MS = 30 * 60 * 1000L
private const val FIX_MAX_AGE_MS = 30 * 60 * 1000L
private const val FIX_TIMEOUT_S = 12L
private val flow = MutableStateFlow<WeatherState>(WeatherState.Loading)
val state: StateFlow<WeatherState> = flow
private val io = Executors.newSingleThreadExecutor { Thread(it, "gearslip-weather").apply { isDaemon = true } }
private var placeCache: Pair<String, String>? = null // rounded "lat,lon" to place name
/** Runs off the main thread. Fresh data younger than [TTL_MS] is kept unless [force]. */
fun refresh(context: Context, force: Boolean = false) {
val current = flow.value
if (!force && current is WeatherState.Ready && System.currentTimeMillis() - current.data.fetchedAt < TTL_MS) return
val app = context.applicationContext
io.execute { load(app) }
}
@Synchronized
private fun load(context: Context) {
// Keep showing the old reading while a new one loads; only the first load shows a spinner.
val location = locate(context)
if (location == null) {
if (flow.value !is WeatherState.Ready) {
val allowed = hasLocationPermission(context)
flow.value = WeatherState.Unavailable(
if (allowed) "Waiting for a location fix." else "Allow location for Gearslip on the phone to see the weather.",
)
}
return
}
val data = runCatching { fetch(context, location) }
.onFailure { GearslipLog.w("weather fetch failed: ${it.message}") }
.getOrNull()
if (data != null) {
flow.value = WeatherState.Ready(data)
GearslipLog.i("weather: ${data.place} ${data.temp}${data.tempUnit} ${describe(data.code)}")
}
else if (flow.value !is WeatherState.Ready) flow.value = WeatherState.Unavailable("Could not reach the weather service.")
}
// --- location -------------------------------------------------------------------------
private fun hasLocationPermission(context: Context) =
context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private fun locate(context: Context): Location? {
if (!hasLocationPermission(context)) return null
val manager = context.getSystemService(LocationManager::class.java) ?: return null
val known = manager.allProviders
.mapNotNull { runCatching { manager.getLastKnownLocation(it) }.getOrNull() }
.maxByOrNull { it.time }
if (known != null && System.currentTimeMillis() - known.time < FIX_MAX_AGE_MS) return known
val provider = listOf(LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
.firstOrNull { it in manager.allProviders && manager.isProviderEnabled(it) }
?: return known
val latch = CountDownLatch(1)
var fresh: Location? = null
runCatching {
manager.getCurrentLocation(provider, null, io) { fresh = it; latch.countDown() }
latch.await(FIX_TIMEOUT_S, TimeUnit.SECONDS)
}.onFailure { GearslipLog.w("weather: fresh fix failed: ${it.message}") }
// A stale fix still gives the right city, which is all a forecast needs.
return fresh ?: known
}
private fun placeName(context: Context, location: Location): String {
val key = "%.2f,%.2f".format(Locale.US, location.latitude, location.longitude)
placeCache?.let { if (it.first == key) return it.second }
val name = runCatching {
if (!Geocoder.isPresent()) return@runCatching null
@Suppress("DEPRECATION")
Geocoder(context, Locale.getDefault()).getFromLocation(location.latitude, location.longitude, 1)
?.firstOrNull()?.let { it.locality ?: it.subAdminArea ?: it.adminArea }
}.getOrNull() ?: "%.2f, %.2f".format(Locale.US, location.latitude, location.longitude)
placeCache = key to name
return name
}
// --- network --------------------------------------------------------------------------
private fun fetch(context: Context, location: Location): WeatherData {
val imperial = Locale.getDefault().country in setOf("US", "LR", "MM")
val url = URL(
"https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}" +
"&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m,is_day" +
"&hourly=temperature_2m,weather_code,precipitation_probability,is_day" +
"&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max" +
"&timezone=auto&timeformat=unixtime&forecast_days=7" +
if (imperial) "&temperature_unit=fahrenheit&wind_speed_unit=mph" else "",
)
val body = (url.openConnection() as HttpURLConnection).run {
connectTimeout = 6_000
readTimeout = 8_000
inputStream.bufferedReader().use { it.readText() }
}
val json = JSONObject(body)
val cur = json.getJSONObject("current")
val hourly = json.getJSONObject("hourly")
val times = hourly.getJSONArray("time")
val nowSec = System.currentTimeMillis() / 1000
val start = (0 until times.length()).firstOrNull { times.getLong(it) >= nowSec - 3600 } ?: 0
val hours = (start until minOf(start + 12, times.length())).map { i ->
HourForecast(
time = times.getLong(i) * 1000,
temp = Math.round(hourly.getJSONArray("temperature_2m").getDouble(i)).toInt(),
code = hourly.getJSONArray("weather_code").getInt(i),
isDay = hourly.getJSONArray("is_day").getInt(i) == 1,
rainChance = hourly.getJSONArray("precipitation_probability").optInt(i, 0),
)
}
val daily = json.getJSONObject("daily")
val dates = daily.getJSONArray("time")
val days = (0 until dates.length()).map { i ->
DayForecast(
date = dates.getLong(i) * 1000,
high = Math.round(daily.getJSONArray("temperature_2m_max").getDouble(i)).toInt(),
low = Math.round(daily.getJSONArray("temperature_2m_min").getDouble(i)).toInt(),
code = daily.getJSONArray("weather_code").getInt(i),
rainChance = daily.getJSONArray("precipitation_probability_max").optInt(i, 0),
)
}
return WeatherData(
place = placeName(context, location),
temp = Math.round(cur.getDouble("temperature_2m")).toInt(),
feelsLike = Math.round(cur.getDouble("apparent_temperature")).toInt(),
code = cur.getInt("weather_code"),
isDay = cur.getInt("is_day") == 1,
humidity = cur.getInt("relative_humidity_2m"),
wind = Math.round(cur.getDouble("wind_speed_10m")).toInt(),
tempUnit = if (imperial) "°F" else "°C",
windUnit = if (imperial) "mph" else "km/h",
hours = hours,
days = days,
fetchedAt = System.currentTimeMillis(),
)
}
/** Open-Meteo's WMO weather codes, in words. */
fun describe(code: Int): String = when (code) {
0 -> "Clear"
1 -> "Mostly clear"
2 -> "Partly cloudy"
3 -> "Overcast"
45, 48 -> "Fog"
in 51..57 -> "Drizzle"
in 61..67 -> "Rain"
in 71..77 -> "Snow"
in 80..82 -> "Rain showers"
in 85..86 -> "Snow showers"
in 95..99 -> "Thunderstorm"
else -> "-"
}
}
@@ -0,0 +1,101 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.cos
import kotlin.math.sin
private val SUN = Color(0xFFFFB300)
private val MOON = Color(0xFFCFD8DC)
private val CLOUD = Color(0xFF90A4AE)
private val RAIN = Color(0xFF29B6F6)
private val SNOW = Color(0xFF81D4FA)
private val BOLT = Color(0xFFFFD54F)
/** Sun, moon, cloud, rain, snow, storm or fog for a WMO weather code. Drawn, so it needs no icon pack. */
@Composable
fun WeatherIcon(code: Int, isDay: Boolean, size: Dp = 28.dp, modifier: Modifier = Modifier) {
Canvas(modifier.size(size)) {
when (code) {
0 -> if (isDay) sun(center, this.size.minDimension * 0.5f) else moon(center, this.size.minDimension * 0.5f)
1, 2 -> {
if (isDay) sun(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
else moon(Offset(this.size.width * 0.38f, this.size.height * 0.36f), this.size.minDimension * 0.36f)
cloud(Offset(this.size.width * 0.06f, this.size.height * 0.34f), Size(this.size.width * 0.9f, this.size.height * 0.6f))
}
3 -> cloud(Offset(0f, this.size.height * 0.2f), Size(this.size.width, this.size.height * 0.7f))
45, 48 -> fog()
in 71..77, in 85..86 -> precip(SNOW, dots = true)
in 95..99 -> storm()
else -> precip(RAIN, dots = false)
}
}
}
private fun DrawScope.sun(c: Offset, r: Float) {
drawCircle(SUN, r * 0.55f, c)
val stroke = r * 0.14f
for (i in 0 until 8) {
val a = Math.toRadians(i * 45.0)
drawLine(
SUN, c + Offset(cos(a).toFloat(), sin(a).toFloat()) * (r * 0.75f),
c + Offset(cos(a).toFloat(), sin(a).toFloat()) * r, stroke, StrokeCap.Round,
)
}
}
private fun DrawScope.moon(c: Offset, r: Float) {
val disc = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c, r * 0.62f)) }
val bite = Path().apply { addOval(androidx.compose.ui.geometry.Rect(c + Offset(r * 0.32f, -r * 0.22f), r * 0.55f)) }
drawPath(Path.combine(androidx.compose.ui.graphics.PathOperation.Difference, disc, bite), MOON)
}
private fun DrawScope.cloud(topLeft: Offset, s: Size) {
val h = s.height
drawCircle(CLOUD, h * 0.42f, topLeft + Offset(s.width * 0.3f, h * 0.55f))
drawCircle(CLOUD, h * 0.55f, topLeft + Offset(s.width * 0.55f, h * 0.42f))
drawCircle(CLOUD, h * 0.38f, topLeft + Offset(s.width * 0.78f, h * 0.62f))
drawRoundRect(CLOUD, topLeft + Offset(s.width * 0.28f, h * 0.55f), Size(s.width * 0.52f, h * 0.45f),
androidx.compose.ui.geometry.CornerRadius(h * 0.2f))
}
private fun DrawScope.precip(color: Color, dots: Boolean) {
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
for (i in 0 until 3) {
val x = size.width * (0.28f + 0.22f * i)
val y = size.height * 0.72f
if (dots) drawCircle(color, size.width * 0.045f, Offset(x, y + size.height * 0.08f))
else drawLine(color, Offset(x, y), Offset(x - size.width * 0.06f, y + size.height * 0.2f), size.width * 0.07f, StrokeCap.Round)
}
}
private fun DrawScope.storm() {
cloud(Offset(0f, 0f), Size(size.width, size.height * 0.62f))
val w = size.width
val h = size.height
val bolt = Path().apply {
moveTo(w * 0.55f, h * 0.55f); lineTo(w * 0.36f, h * 0.82f); lineTo(w * 0.5f, h * 0.82f)
lineTo(w * 0.42f, h * 1f); lineTo(w * 0.66f, h * 0.7f); lineTo(w * 0.52f, h * 0.7f); close()
}
drawPath(bolt, BOLT)
}
private fun DrawScope.fog() {
val stroke = Stroke(width = size.height * 0.09f, cap = StrokeCap.Round)
for (i in 0 until 4) {
val y = size.height * (0.25f + 0.17f * i)
val inset = if (i % 2 == 0) 0.1f else 0.2f
drawLine(CLOUD, Offset(size.width * inset, y), Offset(size.width * (1f - inset), y), stroke.width, stroke.cap)
}
}
@@ -0,0 +1,128 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/** The weather in full: now, the next twelve hours and the week. Opened from the nav bar or the dashboard. */
@Composable
fun WeatherScreen() {
val context = LocalContext.current.applicationContext
val state by Weather.state.collectAsState()
LaunchedEffect(Unit) { Weather.refresh(context) }
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
when (val s = state) {
WeatherState.Loading -> Text("Getting the weather…", style = MaterialTheme.typography.titleMedium)
is WeatherState.Unavailable -> Text(
s.reason, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is WeatherState.Ready -> Forecast(s.data)
}
}
}
@Composable
private fun Forecast(w: WeatherData) {
Text(w.place, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
Row(
Modifier.fillMaxWidth().padding(24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
WeatherIcon(w.code, w.isDay, size = 88.dp)
Column {
Text("${w.temp}${w.tempUnit}", fontSize = 56.sp, fontWeight = FontWeight.Bold)
Text(Weather.describe(w.code), style = MaterialTheme.typography.titleLarge)
}
Spacer(Modifier.weight(1f))
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Feels like ${w.feelsLike}${w.tempUnit}", style = MaterialTheme.typography.bodyLarge)
Text("Humidity ${w.humidity}%", style = MaterialTheme.typography.bodyLarge)
Text("Wind ${w.wind} ${w.windUnit}", style = MaterialTheme.typography.bodyLarge)
}
}
}
Text("Next hours", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(w.hours) { h ->
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Column(
Modifier.width(84.dp).padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
style = MaterialTheme.typography.labelLarge,
)
WeatherIcon(h.code, h.isDay, size = 32.dp)
Text("${h.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
if (h.rainChance > 0) "${h.rainChance}%" else " ",
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
Text("This week", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
w.days.forEachIndexed { i, d ->
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
if (i == 0) "Today" else SimpleDateFormat("EEEE", Locale.getDefault()).format(Date(d.date)),
Modifier.width(120.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold,
)
WeatherIcon(d.code, true, size = 28.dp)
Text(Weather.describe(d.code), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
if (d.rainChance > 0) Text("${d.rainChance}%", color = MaterialTheme.colorScheme.primary)
Text("${d.low}° / ${d.high}°", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
Text(
"Weather data by Open-Meteo.com (CC BY 4.0)",
style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -1,10 +1,14 @@
package app.seb3thehacker.gearslip.car
import android.os.Handler
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -15,16 +19,25 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import org.json.JSONObject
/**
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
* The bottom bar's Back walks the page history first and only leaves the app at the start.
*
* A page's own text inputs can't call up the phone's IME: the car UI lives in a Presentation on
* a virtual display, and the platform's soft keyboard has nowhere sensible to appear there (see
* [CarKeyboard]'s doc comment - every other text field in this app already draws its own for the
* same reason). [FOCUS_TRACKING_JS] reports focus/blur on the page's editable elements, and
* [CarKeyboard] edits the focused one back through injected JS instead of through the DOM's own
* native input path, which this Presentation can't reach.
*/
@Composable
fun WebApp(content: String) {
val navigator = LocalCarNavigator.current
val darkOutside = LocalDarkOutside.current
var web by remember { mutableStateOf<WebView?>(null) }
var fieldText by remember { mutableStateOf<String?>(null) }
DisposableEffect(web) {
navigator.backInterceptor = {
@@ -39,32 +52,49 @@ fun WebApp(content: String) {
onDispose { navigator.backInterceptor = null }
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
webViewClient = LoggingClient()
if (content.startsWith("http://") || content.startsWith("https://")) {
loadUrl(content)
} else {
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
Column(Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.weight(1f).fillMaxSize(),
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
webViewClient = LoggingClient()
addJavascriptInterface(
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField",
)
if (content.startsWith("http://") || content.startsWith("https://")) {
loadUrl(content)
} else {
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
}
web = this
}
web = this
}
},
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
onRelease = { it.destroy() },
)
},
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
onRelease = { it.destroy() },
)
fieldText?.let { text ->
CarKeyboard(
text = text,
onTextChange = { updated ->
fieldText = updated
web?.evaluateJavascript(setFieldValueJs(updated), null)
},
onSubmit = { web?.evaluateJavascript(SUBMIT_FIELD_JS, null) },
)
}
}
}
private class LoggingClient : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url")
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
}
// onPageFinished fires for error pages too, so without this a blocked load (missing
@@ -81,3 +111,70 @@ private class LoggingClient : WebViewClient() {
}
}
}
/** Tells Kotlin when an editable element on the page gains or loses focus, so [CarKeyboard] can show. */
private class FieldFocusBridge(private val onFocus: (String) -> Unit, private val onBlur: () -> Unit) {
private val main = Handler(Looper.getMainLooper())
// WebView calls into this from its own thread, never Compose's.
@JavascriptInterface
fun focused(value: String) { main.post { onFocus(value) } }
@JavascriptInterface
fun blurred() { main.post { onBlur() } }
}
// Plain `el.value = x` never reaches a React-controlled input: React replaces the native value
// setter with its own to track edits, so a direct assignment sets the DOM attribute but leaves
// React's own state (and anything bound to it, like a search box's query) unaware. Calling the
// *native* setter through the prototype, the same trick browser extensions use, is what actually
// notifies it once the 'input' event fires.
private fun setFieldValueJs(value: String) = """
(function() {
var el = document.activeElement;
if (!el) return;
var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
if (setter && setter.set) { setter.set.call(el, ${JSONObject.quote(value)}); } else { el.value = ${JSONObject.quote(value)}; }
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
})();
""".trimIndent()
// Fires a real Enter keydown first - the same event a site's own JS would see from a physical
// keyboard - and only falls back to submitting the form directly if nothing handled it, so a
// search-as-you-type page and a plain <form> both work without submitting twice.
private const val SUBMIT_FIELD_JS = """
(function() {
var el = document.activeElement;
if (!el) return;
var enter = new KeyboardEvent('keydown', {
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
});
var notHandled = el.dispatchEvent(enter);
if (notHandled && el.form) {
if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
}
})();
"""
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
private const val FOCUS_TRACKING_JS = """
(function() {
function isEditable(el) {
if (!el) return false;
var tag = el.tagName;
if (tag === 'TEXTAREA') return true;
if (el.isContentEditable) return true;
if (tag !== 'INPUT') return false;
var skip = ['button', 'submit', 'checkbox', 'radio', 'range', 'color', 'file', 'image', 'reset'];
return skip.indexOf((el.type || 'text').toLowerCase()) === -1;
}
document.addEventListener('focusin', function(e) {
if (isEditable(e.target)) window.GearslipField.focused(e.target.value || '');
});
document.addEventListener('focusout', function(e) {
if (isEditable(e.target)) window.GearslipField.blurred();
});
})();
"""
@@ -1,11 +1,19 @@
package app.seb3thehacker.gearslip.host
import android.content.Context
import android.content.pm.PackageManager
/**
* Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries
* (there are many: one per screen it registers) are noise in every list of apps to open.
*/
object AndroidAuto {
/** Kept in sync with the `<queries><package>` entries in the manifest: package visibility
* hides an app's presence entirely unless one names it (or a broader query matches it), and
* this one has no launcher icon on current builds for a launcher-category query to find. */
private val KNOWN_PACKAGES = listOf("com.google.android.projection.gearhead")
private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto")
fun matches(packageName: String, label: String): Boolean {
@@ -14,4 +22,26 @@ object AndroidAuto {
val name = label.lowercase().replace(Regex("[^a-z ]"), " ")
return "android auto" in name || "androidauto" in name.replace(" ", "")
}
/** The package name of Google's Android Auto, if it is installed. */
fun installedPackage(context: Context): String? {
val manager = context.packageManager
return KNOWN_PACKAGES.firstOrNull { pkg ->
runCatching { manager.getPackageInfo(pkg, 0) }.isSuccess
}
}
/**
* Whether [packageName] can still fight Gearslip for the USB connection: installed, and not
* already disabled for this user. `ApplicationInfo.enabled` only reflects the manifest's
* static default, not a driver's own Settings toggle, so this reads the actual runtime
* setting instead - a driver who disabled it instead of uninstalling it (the app info screen
* offers both) has already done what this step asks.
*/
fun isActive(context: Context, packageName: String): Boolean {
val setting = runCatching { context.packageManager.getApplicationEnabledSetting(packageName) }
.getOrDefault(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)
return setting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT ||
setting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
}
@@ -41,6 +41,7 @@ object CarAppCatalog {
.asSequence()
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
.map { resolved ->
val info = resolved.serviceInfo
TemplateApp(
@@ -31,6 +31,7 @@ import androidx.car.app.suggestion.ISuggestionHost
import androidx.car.app.hardware.ICarHardwareHost
import androidx.car.app.hardware.ICarHardwareResult
import androidx.car.app.navigation.INavigationHost
import androidx.car.app.navigation.INavigationManager
import androidx.car.app.serialization.Bundleable
import androidx.car.app.versioning.CarAppApiLevels
import app.seb3thehacker.gearslip.GearslipLog
@@ -81,6 +82,8 @@ class CarAppConnection(private val context: Context) {
private var carApp: ICarApp? = null
private var appManager: IAppManager? = null
/** Only present for an app that asked for it - most apps aren't navigation apps. */
private var navigationManager: INavigationManager? = null
/** Main thread only, both of these - the handoff is a race between them either way round. */
private var surfaceCallback: ISurfaceCallback? = null
private var lent: Lent? = null
@@ -88,6 +91,9 @@ class CarAppConnection(private val context: Context) {
private var binding: ServiceConnection? = null
private var component: ComponentName? = null
/** Whose icon the header shows for the standard app-icon action. */
val appPackage: String? get() = component?.packageName
private var frameWidth = 800
private var frameHeight = 480
private var frameDensity = 160
@@ -120,20 +126,59 @@ class CarAppConnection(private val context: Context) {
binding = connection
LocationKeepAlive.start(context)
val bound = runCatching {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES)
}.getOrElse {
GearslipLog.e("bindService threw for ${app.component}", it)
false
val bind = {
var bindError: Throwable? = null
val bound = runCatching {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES)
}.getOrElse {
GearslipLog.e("bindService threw for ${app.component}", it)
bindError = it
false
}
if (!bound) {
binding = null
fail(Phase.REJECTED, explainBindFailure(app.component, bindError))
} else {
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
}
}
if (!bound) {
binding = null
fail(Phase.FAILED, "bindService refused")
bind()
}
/**
* A service that names a permission can only be bound by an app holding it. Some navigation apps
* name one that only Google's own Android Auto host holds, and no other app can be granted.
*/
private fun explainBindFailure(component: ComponentName, error: Throwable?): String {
if (error !is SecurityException) return "the app would not let Gearslip connect"
val needed = runCatching { context.packageManager.getServiceInfo(component, 0).permission }.getOrNull()
return if (needed != null) {
"It only accepts Google's own Android Auto: its service requires the permission $needed, which Gearslip can't hold."
} else {
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
"It only accepts Google's own Android Auto host."
}
}
/** The app's own account of why it said no, in words rather than an object id. */
private fun describeFailure(step: String, value: Any?): String {
val failure = value as? androidx.car.app.FailureResponse
?: return "$step failed: ${value ?: "no reason given"}"
val firstLine = failure.stackTrace.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty()
GearslipLog.w("host: $step failed: type=${failure.errorType}\n${failure.stackTrace.lineSequence().take(6).joinToString("\n")}")
// The Car App Library's own host check: the app keeps a list of hosts it trusts by signature.
if (firstLine.contains("Unrecognized host")) {
return "It only trusts hosts on its own allow-list (Google's Android Auto and a few others), and Gearslip isn't on it."
}
val kind = when (failure.errorType) {
androidx.car.app.FailureResponse.SECURITY_EXCEPTION -> "It rejected Gearslip as a host"
androidx.car.app.FailureResponse.ILLEGAL_STATE_EXCEPTION -> "It reported a state error"
androidx.car.app.FailureResponse.INVALID_PARAMETER_EXCEPTION -> "It reported an invalid parameter"
else -> "It failed"
}
return "$kind at $step: ${firstLine.substringAfterLast("Exception: ", firstLine)}"
}
fun disconnect() {
lent?.let { sendSurfaceDestroyed(it) }
lent = null
@@ -147,6 +192,8 @@ class CarAppConnection(private val context: Context) {
binding = null
this.carApp = null
appManager = null
navigationManager = null
_panMode.value = false
surfaceCallback = null
component = null
_template.value = null
@@ -226,6 +273,37 @@ class CarAppConnection(private val context: Context) {
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
requestTemplate()
}) { app.getManager(CarContext.APP_SERVICE, it) }
// Best-effort and silent: an app that never asked for NavigationManager (not every
// templated app navigates) simply has no binder to hand back, which is normal, not a
// failure worth surfacing.
runCatching {
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
override fun onSuccess(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
navigationManager = when (value) {
is INavigationManager -> value
is IBinder -> INavigationManager.Stub.asInterface(value)
else -> null
}
}
override fun onFailure(response: Bundleable?) = Unit
})
}
}
/**
* Tells the app itself to stop navigating - the proper end of a route, not just leaving its
* screen. Ending only the on-screen guidance (a back press) leaves the app's own
* "am I navigating" flag set, which is what stopped a fresh route from starting afterwards;
* this is the call [NavigationManagerCallback.onStopNavigation] on the app's side answers,
* and it is expected to clear that flag and present its own post-navigation screen.
*/
fun stopNavigating() {
val manager = navigationManager ?: return
GearslipLog.i("host: asking the app to stop navigating")
runCatching { manager.onStopNavigation(noop("stop navigation")) }
.onFailure { GearslipLog.w("host: could not stop navigation: ${it.message}") }
}
/** The app describes its current screen; this is what the host draws. */
@@ -244,6 +322,32 @@ class CarAppConnection(private val context: Context) {
}) { manager.getTemplate(it) }
}
private val _panMode = MutableStateFlow(false)
/**
* Pan mode is the host's to run: the Pan action has no click handler, tapping it just switches
* the screen to the map and its own controls. The app is told only if its template asks.
*/
val panMode: StateFlow<Boolean> = _panMode
fun setPanMode(on: Boolean) {
if (_panMode.value == on) return
_panMode.value = on
GearslipLog.i("host: pan mode ${if (on) "on" else "off"}")
(_template.value as? androidx.car.app.navigation.model.NavigationTemplate)
?.panModeDelegate.panModeChanged(on)
}
/**
* The Back action carries no click handler of its own: the app keeps its own screen stack, so
* the host reports the press and the app answers with the template it went back to.
*/
fun backPressed() {
val manager = appManager ?: return
GearslipLog.i("host: back pressed")
call("onBackPressed", onValue = { requestTemplate() }) { manager.onBackPressed(it) }
}
// --- the surface the app draws its map into -------------------------------------------
/**
@@ -498,7 +602,7 @@ class CarAppConnection(private val context: Context) {
override fun onFailure(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() }
main.post { fail(Phase.FAILED, "$name failed: $value") }
main.post { fail(Phase.REJECTED, describeFailure(name, value)) }
}
}
runCatching { send(callback) }
@@ -91,6 +91,17 @@ fun remainingTime(seconds: Long): String {
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
}
/** "1:10 AM" - the clock at the destination, not the phone's own, in case a route crosses a zone. */
fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
val at = this ?: return ""
val shifted = java.util.Date(at.timeSinceEpochMillis + at.zoneOffsetSeconds * 1000L)
return runCatching {
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}.format(shifted)
}.getOrDefault("")
}
/**
* Tells the app something was tapped.
*
@@ -140,6 +151,27 @@ fun InputCallbackDelegate?.inputSubmitted(text: String) {
.onFailure { GearslipLog.w("host: could not submit input: ${it.message}") }
}
/** Tells the app the driver entered or left pan mode, so it can hide its own map controls. */
fun androidx.car.app.navigation.model.PanModeDelegate?.panModeChanged(active: Boolean) {
val delegate = this ?: return
runCatching { delegate.sendPanModeChanged(active, doneCallback("pan mode")) }
.onFailure { GearslipLog.w("host: could not send pan mode: ${it.message}") }
}
/** Tells the app a toggle row was switched; it answers with a template showing the new state. */
fun androidx.car.app.model.OnCheckedChangeDelegate?.checkedChanged(checked: Boolean) {
val delegate = this ?: return
runCatching { delegate.sendCheckedChange(checked, doneCallback("toggle")) }
.onFailure { GearslipLog.w("host: could not send a toggle: ${it.message}") }
}
/** Tells the app which option of a single-choice list was picked. */
fun androidx.car.app.model.OnSelectedDelegate?.selected(index: Int) {
val delegate = this ?: return
runCatching { delegate.sendSelected(index, doneCallback("selection")) }
.onFailure { GearslipLog.w("host: could not send a selection: ${it.message}") }
}
/** The driver asked for fresher content, e.g. by pulling a place list. */
fun OnContentRefreshDelegate?.refresh() {
val delegate = this ?: return
@@ -0,0 +1,41 @@
package app.seb3thehacker.gearslip.host
/**
* Apps that have been run through Gearslip from start to finish and behave. The launcher marks
* them so a driver can pick something that works without finding out at the wheel.
*
* An app belongs here only after a real session: the app starts, draws, takes touches and
* answers its buttons. Apps that connect but show no library, or refuse Gearslip as a host, stay
* off the list.
*/
object KnownApps {
private val working = setOf(
"app.organicmaps", // Organic Maps: map, search, routing, settings
"net.osmand.plus", // OsmAnd: map, search, routing, settings
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
)
fun works(packageName: String): Boolean = packageName in working
/**
* Registers a car template service but never gives Gearslip a usable screen - left off the
* launcher entirely rather than shown as a tile that goes nowhere.
*/
private val hidden = setOf(
"com.google.android.dialer", // Google Phone
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
)
fun isHidden(packageName: String): Boolean = packageName in hidden
/** Shown on the launcher with a red X: known broken, but expected enough that hiding it outright would look like a bug. */
private val broken = setOf(
"com.google.android.apps.messaging", // Google Messages
"com.waze", // Waze
)
fun isBroken(packageName: String): Boolean = packageName in broken
}
@@ -1,15 +1,21 @@
package app.seb3thehacker.gearslip.media
import android.content.ComponentName
import android.content.Context
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.media.browse.MediaBrowser
import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -28,6 +34,15 @@ class MediaEntry(
class CustomAction(val id: String, val name: String, val extras: Bundle?)
/** One entry in the app's own queue - not its library, whatever it has lined up to play next. */
class QueueTrack(
val queueId: Long,
val title: String,
val subtitle: String,
val iconUri: Uri?,
val iconBitmap: Bitmap?,
)
/** What is playing, as the car's now-playing panel needs it. */
class NowPlaying(
val title: String = "",
@@ -43,10 +58,21 @@ class NowPlaying(
val actions: Long = 0,
val custom: List<CustomAction> = emptyList(),
val error: String? = null,
/** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */
val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(),
) {
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
/**
* Something is actually playing or paused mid-track. A stopped, errored or empty session, or one
* that has not named a track yet, must not keep the player on screen.
*/
val isActive get() = title.isNotEmpty() && when (state) {
PlaybackState.STATE_NONE, PlaybackState.STATE_STOPPED, PlaybackState.STATE_ERROR -> false
else -> true
}
fun canDo(action: Long) = actions and action != 0L
/** The position now, advanced from the last report the way the platform's own UIs do. */
@@ -64,6 +90,8 @@ class BrowseState(
val entries: List<MediaEntry> = emptyList(),
val loading: Boolean = true,
val failed: Boolean = false,
/** The app connected but shares no library with us, so there is nothing to browse - only to control. */
val unavailable: Boolean = false,
)
/**
@@ -75,6 +103,15 @@ 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 }
private val _rejection = MutableStateFlow(Rejection.REFUSED)
val rejection: StateFlow<Rejection> = _rejection.asStateFlow()
private val main = Handler(Looper.getMainLooper())
private var connecting: MediaApp? = null
private val _phase = MutableStateFlow(Phase.IDLE)
val phase: StateFlow<Phase> = _phase.asStateFlow()
@@ -84,6 +121,9 @@ class CarMedia(private val context: Context) {
private val _now = MutableStateFlow(NowPlaying())
val now: StateFlow<NowPlaying> = _now.asStateFlow()
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
private var browser: MediaBrowser? = null
private var controller: MediaController? = null
private var subscribed: String? = null
@@ -92,13 +132,14 @@ class CarMedia(private val context: Context) {
fun connect(app: MediaApp) {
disconnect()
connecting = app
_phase.value = Phase.CONNECTING
_browse.value = BrowseState()
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
override fun onConnected() = this@CarMedia.onConnected(app)
override fun onConnectionFailed() {
GearslipLog.w("media: ${app.label} refused the connection")
_phase.value = Phase.REJECTED
GearslipLog.w("media: ${app.label} refused the connection to its library")
attachToSession(app)
}
override fun onConnectionSuspended() {
GearslipLog.w("media: ${app.label} went away")
@@ -112,12 +153,52 @@ class CarMedia(private val context: Context) {
}
}
/**
* Some apps only let Google's own host browse them. Their playback session is still public, so
* playback can be driven from it: what is playing, play and pause, skip, seek. Only the library
* 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) {
if (connecting != app) return
val manager = context.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(context, GearslipNotificationListener::class.java)
val session = try {
manager.getActiveSessions(listener).firstOrNull { it.packageName == app.component.packageName }
} catch (e: SecurityException) {
GearslipLog.w("media: cannot read ${app.label}'s session without notification access")
_rejection.value = Rejection.NEEDS_NOTIFICATION_ACCESS
_phase.value = Phase.REJECTED
return
}
if (session == null) {
// 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)
} else {
GearslipLog.w("media: ${app.label} has no active session to control")
_rejection.value = Rejection.REFUSED
_phase.value = Phase.REJECTED
}
return
}
GearslipLog.i("media: controlling ${app.label} through its session; its library is not shared")
controller = session.also {
it.registerCallback(callback)
update(it.metadata, it.playbackState)
_queue.value = queueOf(it.queue)
}
_browse.value = BrowseState(loading = false, unavailable = true)
_phase.value = Phase.READY
}
private fun onConnected(app: MediaApp) {
val mb = browser ?: return
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
controller = MediaController(context, mb.sessionToken).also {
it.registerCallback(callback)
update(it.metadata, it.playbackState)
_queue.value = queueOf(it.queue)
}
rootId = mb.root
path.clear()
@@ -128,6 +209,20 @@ class CarMedia(private val context: Context) {
private val callback = object : MediaController.Callback() {
override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState)
override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state)
override fun onQueueChanged(queue: MutableList<MediaSession.QueueItem>?) {
_queue.value = queueOf(queue)
}
}
private fun queueOf(items: List<MediaSession.QueueItem>?): List<QueueTrack> = items.orEmpty().map { item ->
val d = item.description
QueueTrack(
queueId = item.queueId,
title = d.title?.toString().orEmpty(),
subtitle = d.subtitle?.toString().orEmpty(),
iconUri = d.iconUri,
iconBitmap = d.iconBitmap,
)
}
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
@@ -148,6 +243,7 @@ class CarMedia(private val context: Context) {
actions = state?.actions ?: 0,
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) },
error = state?.errorMessage?.toString(),
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
)
}
@@ -165,6 +261,8 @@ class CarMedia(private val context: Context) {
trail = path.map { it.second },
entries = children.map(::entryOf),
loading = false,
// An empty top level is an app keeping its library to itself, not an empty one.
unavailable = path.isEmpty() && children.isEmpty(),
)
}
override fun onError(parentId: String) {
@@ -220,8 +318,11 @@ class CarMedia(private val context: Context) {
fun previous() = controller?.transportControls?.skipToPrevious()
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
fun disconnect() {
connecting = null
main.removeCallbacksAndMessages(null)
runCatching { controller?.unregisterCallback(callback) }
runCatching { subscribed?.let { browser?.unsubscribe(it) } }
runCatching { browser?.disconnect() }
@@ -230,5 +331,11 @@ class CarMedia(private val context: Context) {
subscribed = null
_phase.value = Phase.IDLE
_now.value = NowPlaying()
_queue.value = emptyList()
}
private companion object {
const val SESSION_RETRIES = 6
const val SESSION_RETRY_MS = 500L
}
}
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.host.KnownApps
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -33,6 +34,7 @@ object MediaCatalog {
.asSequence()
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
.filter { declaresCar(manager, it.serviceInfo) }
.map {
val info = it.serviceInfo
@@ -24,6 +24,14 @@ object PhoneApps {
private const val ICON_PX = 128
@Volatile private var cache: List<PhoneApp>? = null
/** What the last scan found, or null before the first one. */
fun cached(): List<PhoneApp>? = cache
/** Scans and stores the result; [Prefetch] calls this so the screen opens already filled. */
fun warm(context: Context) { cache = installed(context) }
fun installed(context: Context): List<PhoneApp> {
val manager = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
@@ -3,7 +3,7 @@ package app.seb3thehacker.gearslip.ui
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.HardwareBuffer
@@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowInsetsCompat
@@ -50,6 +51,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.ScreenProjector
import app.seb3thehacker.gearslip.TouchPoint
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarUi
@@ -69,7 +71,9 @@ fun CarPreviewScreen(onBack: () -> Unit) {
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
var viewSize by remember { mutableStateOf(0 to 0) }
var fullscreen by remember { mutableStateOf(false) }
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val width = frame.width
val height = frame.height
@@ -122,8 +126,14 @@ fun CarPreviewScreen(onBack: () -> Unit) {
if (viewWidth > 0 && viewHeight > 0) {
projector.dispatchTouch(
event.actionMasked.toCarAction(),
event.x * width / viewWidth,
event.y * height / viewHeight,
event.actionIndex,
List(event.pointerCount) { i ->
TouchPoint(
event.getPointerId(i),
event.getX(i) * width / viewWidth,
event.getY(i) * height / viewHeight,
)
},
)
}
true
@@ -142,7 +152,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
}
if (fullscreen) {
Fullscreen(landscape = width > height, onExit = { fullscreen = false }) {
Fullscreen(onBack) {
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
// whole height, whichever the car's shape allows, and the rest stays black.
frameContent(Modifier)
@@ -167,7 +177,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
Row {
TextButton(onClick = onBack) { Text("Back") }
TextButton(onClick = { fullscreen = true }) { Text("Fullscreen") }
}
}
}
@@ -177,34 +186,32 @@ fun CarPreviewScreen(onBack: () -> Unit) {
* Fills the phone screen with [content], centred and letterboxed.
*
* A car screen is wider than it is tall, so a portrait phone would waste most of its height on
* one. While this is showing, the activity is turned to match and the system bars go away - both
* are undone on the way out, so the rest of the phone UI is unaffected.
* one. This only shows once the phone is already turned sideways (the caller drives it off the
* phone's own orientation), so there is no rotation left to force here - just the system bars to
* hide while it's up and restore on the way out.
*/
@Composable
private fun Fullscreen(
landscape: Boolean,
onExit: () -> Unit,
onBack: () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
val activity = LocalContext.current.activity()
DisposableEffect(activity, landscape) {
DisposableEffect(activity) {
if (activity == null) return@DisposableEffect onDispose { }
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
val previousOrientation = activity.requestedOrientation
if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
onDispose {
insets.show(WindowInsetsCompat.Type.systemBars())
activity.requestedOrientation = previousOrientation
}
onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
}
BackHandler(onBack = onExit)
// Turning the phone back upright is what normally leaves fullscreen; back still leaves the
// preview screen entirely rather than doing nothing, in case the phone's own rotation lock
// is on and turning it sideways/back isn't an option.
BackHandler(onBack = onBack)
Box(
Modifier.fillMaxSize().background(Color.Black),
@@ -215,10 +222,10 @@ private fun Fullscreen(
// The frame swallows every touch inside it, so leaving needs either the back gesture or
// a button of its own. This one sits in the corner the car UI leaves emptiest.
FilledTonalIconButton(
onClick = onExit,
onClick = onBack,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
) {
Icon(Icons.Filled.Close, contentDescription = "Leave fullscreen")
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
}
}
}
@@ -230,10 +237,11 @@ private tailrec fun Context.activity(): Activity? = when (this) {
else -> null
}
/** Only the four actions [ScreenProjector.dispatchTouch] understands reach the car UI. */
/** The actions [ScreenProjector.dispatchTouch] understands reach the car UI, second fingers included. */
private fun Int.toCarAction(): Int = when (this) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE,
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
-> this
else -> MotionEvent.ACTION_CANCEL
}
@@ -0,0 +1,265 @@
package app.seb3thehacker.gearslip.ui
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.CertProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Shown when Gearslip has no certificate loaded. Download is the one clear path; importing your
* own file is offered as a smaller, secondary option underneath it.
*
* The app ships no certificates. Skipping generates a self-signed one, which a real head unit
* rejects.
*/
@Composable
fun CertSetupScreen(onDone: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
var showImport by remember { mutableStateOf(false) }
var pendingUri by remember { mutableStateOf<Uri?>(null) }
var manualProgress by remember { mutableStateOf(false) }
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) pendingUri = uri
}
val installed = manualProgress || downloadState is DownloadState.Success
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
"Certificate Setup",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Text(
if (installed) {
"Cars accept only Google-issued certificates, and you have one loaded."
} else {
"Cars accept only Google-issued certificates. Download one to connect."
},
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
if (installed) {
Text(
if (manualProgress) "Certificate imported." else "Certificate installed.",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(20.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Continue")
}
Spacer(Modifier.height(40.dp))
return@Column
}
when (downloadState) {
is DownloadState.Idle -> {
Button(
onClick = {
downloadState = DownloadState.Loading
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { CertProvider.downloadAndImport(context) }
}
downloadState = result.fold(
onSuccess = { DownloadState.Success(it.certificate.subjectX500Principal.name) },
onFailure = { DownloadState.Error(it.message ?: "Unknown error") },
)
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text("Download certificate")
}
Text(
"Works with most cars built before 2020. Newer cars reject it.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
is DownloadState.Loading -> {
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
DownloadingRow {
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
Text("Downloading…", style = MaterialTheme.typography.bodyLarge)
}
}
}
is DownloadState.Success -> Unit // handled above via `installed`
is DownloadState.Error -> {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Download failed",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Text(
(downloadState as DownloadState.Error).message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
)
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
Text("Retry")
}
}
}
}
}
Spacer(Modifier.height(16.dp))
if (showImport) {
OutlinedButton(
onClick = { importLauncher.launch(arrayOf("*/*")) },
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Import your own .p12 cert") }
Spacer(Modifier.height(16.dp))
} else {
TextButton(onClick = { showImport = true }) { Text("Import your own certificate instead") }
Spacer(Modifier.height(8.dp))
}
OutlinedButton(
onClick = {
AppSettings.setSkippedCertSetup(context)
onDone()
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text("Skip car connection setup")
}
Spacer(Modifier.height(40.dp))
}
}
pendingUri?.let { uri ->
PasswordDialog(
onDismiss = { pendingUri = null },
onConfirm = { password, reportError ->
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { CertProvider.importFrom(context, uri, password) }
}
result.onSuccess {
pendingUri = null
manualProgress = true
}.onFailure {
reportError("Wrong password or not a valid PKCS#12 file with a private key.")
}
}
},
)
}
}
/** A tight horizontal row for the inline "Downloading…" spinner state. */
@Composable
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
private sealed class DownloadState {
data object Idle : DownloadState()
data object Loading : DownloadState()
data class Success(val subjectName: String) : DownloadState()
data class Error(val message: String) : DownloadState()
}
@Composable
private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -> Unit) -> Unit) {
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Certificate password") },
text = {
OutlinedTextField(
value = password,
onValueChange = { password = it; error = null },
singleLine = true,
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
isError = error != null,
supportingText = { Text(error ?: "Leave empty if the file has none.") },
)
},
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -19,12 +19,17 @@ data class CertSummary(
val identity = CertProvider.loadSupplied(context)
?: return CertSummary(
CertProvider.Kind.SELF_SIGNED,
"None loaded - a self-signed one is generated, which real head units reject",
"None loaded (self-signed; cars reject it)",
null, null, null,
)
val cert = identity.certificate
val name = friendlyName(cert.subjectX500Principal.name)
val prefix = if (identity.kind == CertProvider.Kind.IMPORTED) "Imported" else "Staged over adb"
val prefix = when (identity.kind) {
CertProvider.Kind.IMPORTED -> "Imported"
CertProvider.Kind.DOWNLOADED -> "Downloaded"
CertProvider.Kind.ADB_STAGED -> "Staged over adb"
CertProvider.Kind.SELF_SIGNED -> "Self-signed"
}
return CertSummary(
identity.kind,
"$prefix: $name",
@@ -0,0 +1,50 @@
package app.seb3thehacker.gearslip.ui
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
/**
* A command to run on a computer, shown so it can be copied rather than retyped. Tapping it puts
* it on the clipboard, which is the only way it reaches the computer from here anyway.
*/
@Composable
fun CopyableCommand(command: String) {
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = MaterialTheme.shapes.small,
onClick = {
clipboard.setText(AnnotatedString(command))
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
command,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
"Tap to copy",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@@ -7,17 +7,32 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.CertProvider
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW }
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP }
/** Three screens and a back stack of depth one: no navigation library needed. */
@Composable
fun GearslipApp(onDisconnect: () -> Unit) {
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
val context = LocalContext.current
val needsCertSetup = remember {
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
}
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
var screen by rememberSaveable {
mutableStateOf(
when {
needsCertSetup -> Screen.CERT_SETUP
needsSetupGuide -> Screen.SETUP_GUIDE
else -> Screen.HOME
},
)
}
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
when (screen) {
@@ -25,11 +40,22 @@ fun GearslipApp(onDisconnect: () -> Unit) {
onOpenLogs = { screen = Screen.LOGS },
onOpenSettings = { screen = Screen.SETTINGS },
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
onOpenHelp = { screen = Screen.HELP },
onDisconnect = onDisconnect,
)
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
Screen.HELP -> HelpScreen(onBack = { screen = Screen.HOME }, onRequestCallScreening = onRequestCallScreening)
Screen.CERT_SETUP -> CertSetupScreen(
onDone = {
screen = if (AppSettings.hasSeenPermissionsSetup(context)) Screen.HOME else Screen.SETUP_GUIDE
},
)
Screen.SETUP_GUIDE -> SetupGuideScreen(
onRequestCallScreening = onRequestCallScreening,
onDone = { screen = Screen.HOME },
)
}
CompatWarning()
@@ -47,9 +73,8 @@ private fun CompatWarning() {
title = { Text("Before you plug in") },
text = {
Text(
"Gearslip may not connect on cars or head units newer than about 2020. " +
"Older systems are more likely to work; newer firmware often rejects the " +
"connection outright. See SPIKE_FINDINGS.md in the project for details.",
"Gearslip works with cars built before 2020. " +
"Newer firmware often blocks the connection.",
)
},
confirmButton = {
@@ -0,0 +1,157 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* One step of the setup list: a sentence that says what to do, and optionally how.
*
* [command] is for a step that cannot be done on the phone; it is shown as something to copy
* rather than to read, because a mistyped package name fails silently. [onClick] is for a step
* that can, in one tap, in which case it is shown as a button instead.
*/
private class Step(
val action: String,
val detail: String? = null,
val command: String? = null,
val buttonLabel: String? = null,
val onClick: (() -> Unit)? = null,
)
private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
Step(
"On GrapheneOS, exempt Gearslip from exploit protections.",
"Open Settings, then Apps, then Gearslip, and turn on Exploit protection compatibility mode.",
),
Step("Disable or uninstall the Android Auto app."),
Step("Load a certificate.", "Open Settings, then download or import a certificate."),
Step(
"Allow the microphone, so music can reach the car.",
"Android asks for the microphone before it will let any app pass music along. " +
"Gearslip never listens to you: it copies what a music app is playing, and nothing else.",
),
Step(
"Optional: stop Android asking about audio every drive.",
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
"still works, but you have to tap Start once each time you set off.",
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
),
Step(
"Optional: keep notifications readable while music plays.",
"Android treats sending audio to the car like sharing your screen, and hides what your " +
"notifications say. Run this command too, and Gearslip switches that off only while " +
"music is going to the car, then puts it back.",
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
),
Step(
"Optional: let the car screen answer and decline calls.",
"This sets Gearslip as your phone's caller ID and spam-blocking app, the same kind of " +
"consent a call-blocking app asks for. Without it, calls still ring, but only the " +
"phone itself can answer or decline them.",
buttonLabel = "Set up",
onClick = onRequestCallScreening,
),
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
)
/** The full setup reference, behind the help button: what the one-time guide walks through, kept
* here too so it can be reread without repeating the guide itself. */
@Composable
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) { Text("‹", style = MaterialTheme.typography.headlineMedium) }
Text(
"How to connect",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
}
Spacer(Modifier.height(16.dp))
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
steps.forEachIndexed { index, step ->
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text(
"${index + 1}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(16.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
step.detail?.let {
Text(
it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
step.command?.let { CopyableCommand(it) }
if (step.onClick != null) {
FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) {
Text(step.buttonLabel ?: "Open")
}
}
}
}
}
}
Spacer(Modifier.height(32.dp))
}
}
}
/** The small circular "?" button that opens [HelpScreen] from the top right of the home screen. */
@Composable
fun HelpButton(onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Box(Modifier.padding(10.dp), contentAlignment = Alignment.Center) {
Text(
"?",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
@@ -18,16 +18,21 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.SessionStatus
@Composable
@@ -35,9 +40,12 @@ fun HomeScreen(
onOpenLogs: () -> Unit,
onOpenSettings: () -> Unit,
onOpenCarPreview: () -> Unit,
onOpenHelp: () -> Unit,
onDisconnect: () -> Unit,
) {
val status by SessionStatus.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var debug by remember { mutableStateOf(AppSettings.debugMode(context)) }
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
@@ -52,19 +60,44 @@ fun HomeScreen(
.verticalScroll(rememberScrollState()),
) {
Spacer(Modifier.height(24.dp))
Text(
"Gearslip",
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"v${LocalContext.current.appVersionName()}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text(
"Gearslip",
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
Text(
"v${LocalContext.current.appVersionName()}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HelpButton(onClick = onOpenHelp)
}
Spacer(Modifier.height(16.dp))
StatusCard(status)
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text("Debug mode", style = MaterialTheme.typography.titleMedium)
Text(
if (debug) "Full protocol logging, live logs and car preview."
else "Quiet logging. Warnings and errors are still recorded.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = debug, onCheckedChange = {
debug = it
AppSettings.setDebugMode(context, it)
})
}
Spacer(Modifier.height(16.dp))
}
@@ -80,7 +113,7 @@ fun HomeScreen(
Text("Disconnect")
}
}
if (!live) {
if (!live && debug) {
FilledTonalButton(
onClick = onOpenCarPreview,
modifier = Modifier.fillMaxWidth().height(56.dp),
@@ -89,8 +122,10 @@ fun HomeScreen(
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Live logs")
if (debug) {
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Live logs")
}
}
OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Settings")
@@ -1,6 +1,14 @@
package app.seb3thehacker.gearslip.ui
import android.content.Intent
import androidx.compose.material3.Surface
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Button
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -17,7 +25,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -78,20 +85,34 @@ fun LogsScreen(onBack: () -> Unit) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = {
scope.launch {
val text = withContext(Dispatchers.IO) { GearslipLog.fullText() }
)
},
bottomBar = {
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
Row(
Modifier.fillMaxWidth().navigationBarsPadding().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = {
scope.launch {
val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
val send = Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log")
.putExtra(Intent.EXTRA_TEXT, text)
.putExtra(Intent.EXTRA_STREAM, uri)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
context.startActivity(Intent.createChooser(send, "Share log"))
}
}) { Text("Share") }
TextButton(onClick = { GearslipLog.clearLive() }) { Text("Clear") }
},
)
},
modifier = Modifier.weight(1f).height(56.dp),
) { Text("Share") }
OutlinedButton(
onClick = { GearslipLog.clearLive() },
modifier = Modifier.weight(1f).height(56.dp),
) { Text("Clear") }
}
}
},
floatingActionButton = {
if (!follow && lines.isNotEmpty()) {
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -124,6 +125,26 @@ private fun CertificateSection() {
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
Text("Import certificate (.p12)")
}
FilledTonalButton(
onClick = {
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { CertProvider.downloadAndImport(context) }
}
result.onSuccess {
notice = "Certificate downloaded."
refresh++
}.onFailure {
notice = "Download failed: ${it.message}"
}
}
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
Text("Download certificate")
}
if (summary?.kind == CertProvider.Kind.IMPORTED) {
OutlinedButton(
onClick = {
@@ -134,9 +155,21 @@ private fun CertificateSection() {
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Remove imported certificate") }
}
if (summary?.kind == CertProvider.Kind.DOWNLOADED) {
OutlinedButton(
onClick = {
CertProvider.removeDownloaded(context)
notice = "Removed the downloaded certificate."
refresh++
},
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text("Remove downloaded certificate") }
}
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
Text(
"An imported certificate takes priority over one staged over adb. Takes effect on the next connection.",
"Changes take effect on the next connection.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -0,0 +1,379 @@
package app.seb3thehacker.gearslip.ui
import android.Manifest
import android.app.AppOpsManager
import android.app.NotificationManager
import android.app.role.RoleManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.call.CarCalls
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.Prefetch
import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
/**
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
* command to copy; [actionLabel] with [onAction] is a button that does something else (opens a
* settings screen) without by itself finishing the page. Everything is skippable: none of this
* can be verified as done, only offered.
*/
private class GuidePage(
val title: String,
val explanation: String,
val permissions: Array<String>? = null,
val command: String? = null,
val actionLabel: String? = null,
val onAction: (() -> Unit)? = null,
)
/** True once every permission in [permissions] is already granted - nothing left for this page to do. */
private fun granted(context: Context, permissions: Array<String>): Boolean =
permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
/** Whether Gearslip already holds the hidden `PROJECT_MEDIA` app-op an adb command grants -
* checked the same way the platform checks it, since there is no public permission for it. */
private fun hasProjectMediaOp(context: Context): Boolean {
val ops = context.getSystemService(AppOpsManager::class.java) ?: return false
val mode = runCatching {
ops.unsafeCheckOpNoThrow("android:project_media", android.os.Process.myUid(), context.packageName)
}.getOrDefault(AppOpsManager.MODE_IGNORED)
return mode == AppOpsManager.MODE_ALLOWED
}
private fun hasCallScreeningRole(context: Context): Boolean {
val roleManager = context.getSystemService(RoleManager::class.java) ?: return false
return runCatching { roleManager.isRoleHeld(RoleManager.ROLE_CALL_SCREENING) }.getOrDefault(false)
}
/** Whether the driver has already granted Gearslip's [GearslipNotificationListener] access. */
private fun hasNotificationAccess(context: Context): Boolean {
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
val component = ComponentName(context, GearslipNotificationListener::class.java)
return runCatching { manager.isNotificationListenerAccessGranted(component) }.getOrDefault(false)
}
private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<GuidePage> = buildList {
// --- Permissions: skipped outright once already granted, nothing left to ask -----
if (CarSettings.pipeAudio.value && !granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
add(
GuidePage(
title = "Microphone",
explanation = "Android will not pass a music app's audio to the car without this " +
"permission. Gearslip does not listen to you: it copies what is already " +
"playing, nothing else.",
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
),
)
}
if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
add(
GuidePage(
title = "Location",
explanation = "The car's map and nearby places need to know where you are. " +
"Without it, the dashboard has no map card.",
permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
),
)
}
if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) {
add(
GuidePage(
title = "Calendar",
explanation = "Gearslip reads your next event to show an agenda card on the car " +
"screen. It only reads; it never edits or deletes anything.",
permissions = arrayOf(Manifest.permission.READ_CALENDAR),
),
)
}
val callPermissions = arrayOf(
Manifest.permission.READ_PHONE_STATE,
Manifest.permission.READ_CALL_LOG,
Manifest.permission.READ_CONTACTS,
Manifest.permission.ANSWER_PHONE_CALLS,
Manifest.permission.CALL_PHONE,
)
if (!granted(context, callPermissions)) {
add(
GuidePage(
title = "Phone and contacts",
explanation = "When a call comes in, Gearslip matches the number against your " +
"contacts, so the car screen can show a name instead of just digits. The " +
"same permissions let the car screen's Phone app dial a number or call " +
"someone from your contacts.",
permissions = callPermissions,
),
)
}
if (!hasNotificationAccess(context)) {
add(
GuidePage(
title = "Notification access",
explanation = "The car screen shows your phone's notifications and lets you " +
"reply from there. Android only allows that once you grant Gearslip access, " +
"from the list this opens - it needs this to work at all.",
actionLabel = "Open notification access",
onAction = {
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
},
),
)
}
// --- GrapheneOS ------------------------------------------------------------------
add(
GuidePage(
title = "GrapheneOS exploit protection",
explanation = "On GrapheneOS only: open Gearslip's app settings and turn on Exploit " +
"protection compatibility mode. Gearslip's native USB and video code trips " +
"GrapheneOS's hardened memory checks otherwise. Skip this on any other Android.",
actionLabel = "Open app settings",
onAction = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
},
),
)
// --- Android Auto ------------------------------------------------------------------
val androidAutoPackage = AndroidAuto.installedPackage(context)
?.takeIf { AndroidAuto.isActive(context, it) }
if (androidAutoPackage != null) {
add(
GuidePage(
title = "Remove Android Auto",
explanation = "Android Auto is still installed. It claims the same USB " +
"connection Gearslip needs, so the two fight over it. Disable or uninstall " +
"it from the app info screen this opens.",
actionLabel = "Open Android Auto's app info",
onAction = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", androidAutoPackage, null)),
)
},
),
)
}
// --- Default handler ---------------------------------------------------------------
add(
GuidePage(
title = "Make Gearslip the default",
explanation = "Android has no separate default-app setting for a USB accessory like " +
"the car. It just launches whichever single app matches - which is Gearslip, " +
"now that Android Auto is out of the way. If a chooser still appears when you " +
"plug in, pick Gearslip and tap Always so Android stops asking.",
),
)
// --- Optional: each is skipped once its actual effect is already in place --------
if (!hasProjectMediaOp(context)) {
add(
GuidePage(
title = "Optional: trust audio every drive",
explanation = "Connect the phone to a computer with USB debugging on, and run this " +
"command. It tells Android to trust Gearslip with audio from now on. Skip it " +
"and everything still works, but you tap Start once each time you set off.",
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
),
)
}
if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) {
add(
GuidePage(
title = "Optional: keep notifications readable",
explanation = "Android treats sending audio to the car like sharing your screen, and " +
"hides what your notifications say. This command switches that off only while " +
"music is going to the car, then puts it back.",
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
),
)
}
if (!hasCallScreeningRole(context)) {
add(
GuidePage(
title = "Optional: answer and decline calls",
explanation = "This sets Gearslip as your phone's caller ID and spam-blocking app, " +
"the same kind of consent a call-blocking app asks for. Without it, calls still " +
"ring, but only the phone itself can answer or decline them.",
actionLabel = "Set up",
onAction = onRequestCallScreening,
),
)
}
}
/**
* The first-run guide, one page at a time: permissions, then GrapheneOS's exploit-protection
* toggle, then removing Android Auto if it is still installed, then the optional extras. Nothing
* here can be verified as done except a permission grant, so every other page is a "Next" a
* driver takes on trust, with "Skip" sitting right next to it.
*/
@Composable
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
val context = LocalContext.current
val pages = remember { pages(context, onRequestCallScreening) }
var index by remember { mutableIntStateOf(0) }
// Only ours to handle once there is a previous page to return to; at the first page, the
// system back press falls through to whatever the screen beneath this one does with it.
BackHandler(enabled = index > 0) { index -= 1 }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
if (result.values.any { it }) {
Prefetch.warm(context, force = true)
CarCalls.start(context)
}
index += 1
}
val page = pages.getOrNull(index)
if (page == null) {
AppSettings.setSeenPermissionsSetup(context)
onDone()
return
}
val alreadyGranted = page.permissions?.all {
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
} ?: false
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LinearProgressIndicator(
progress = { (index + 1f) / pages.size },
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
)
Spacer(Modifier.height(12.dp))
Text(
"${index + 1} of ${pages.size}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(48.dp))
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
page.title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
page.explanation,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
page.command?.let { CopyableCommand(it) }
}
}
Spacer(Modifier.height(32.dp))
when {
page.permissions != null && alreadyGranted -> {
Text(
"Already allowed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(16.dp))
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
}
}
page.permissions != null -> {
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
Button(onClick = { launcher.launch(page.permissions) }, modifier = it) { Text("Allow") }
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = { index += 1 }) { Text("Skip for now") }
}
page.onAction != null -> {
FilledTonalButton(
onClick = page.onAction,
modifier = Modifier.fillMaxWidth().height(52.dp),
) { Text(page.actionLabel ?: "Open") }
Spacer(Modifier.height(8.dp))
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
}
}
else -> {
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
}
}
}
}
}
}
/**
* The bottom action row: a lower-emphasis Back on the left once there is a previous page, and the
* page's own primary button - passed [content] so the caller keeps the button's own label and
* click behavior - filling the rest of the width.
*/
@Composable
private fun PrimaryRow(showBack: Boolean, onBack: () -> Unit, content: @Composable (Modifier) -> Unit) {
if (showBack) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = onBack,
modifier = Modifier.weight(1f).height(52.dp),
) { Text("Back") }
content(Modifier.weight(1f).height(52.dp))
}
} else {
content(Modifier.fillMaxWidth().height(52.dp))
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Only the log copies made for sharing; nothing else in the app is exposed. -->
<cache-path name="shared_logs" path="shared/" />
</paths>
@@ -0,0 +1,94 @@
package app.seb3thehacker.gearslip
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
/**
* Parses the ServiceDiscoveryResponse the 2018 Uconnect actually sent, rebuilt field for field
* from the decoded tree in `archive/spike/car-run-2026-09-16-run2.log`.
*
* The car offers two audio sinks - media on channel 5 and guidance on channel 6 - alongside the
* video sink on channel 1 and a microphone source on channel 3. Picking the right one out of that
* set is the whole job of [ServiceDiscovery.findAudioServices], and getting it wrong is silent:
* audio simply never arrives.
*/
class ServiceDiscoveryAudioTest {
private fun varint(value: Long): ByteArray = Protobuf.varint(value)
private fun varintField(field: Int, value: Long) = Protobuf.varintField(field, value)
private fun lenField(field: Int, body: ByteArray): ByteArray =
varint(((field shl 3) or 2).toLong()) + varint(body.size.toLong()) + body
/** AudioConfiguration { sampling_rate = 1; bits = 2; channels = 3 } */
private fun audioConfig(rate: Int, bits: Int, channels: Int) =
varintField(1, rate.toLong()) + varintField(2, bits.toLong()) + varintField(3, channels.toLong())
/** Service { id = 1; media_sink_service = 3 } carrying an audio sink. */
private fun audioSink(id: Int, codec: Int, audioType: Int, config: ByteArray) =
lenField(
1,
varintField(1, id.toLong()) +
lenField(3, varintField(1, codec.toLong()) + varintField(2, audioType.toLong()) + lenField(3, config)),
)
/** Channel 1: the H.264 video sink, which must not be mistaken for an audio one. */
private fun videoSink(): ByteArray {
val videoConfig = varintField(1, 2L) + varintField(2, 2L) + varintField(3, 0L) +
varintField(4, 12L) + varintField(5, 240L) + varintField(6, 0L) + varintField(8, 10_000L)
return lenField(
1,
varintField(1, 1L) + lenField(3, varintField(1, 3L) + lenField(4, videoConfig)),
)
}
/** Channel 3: the car's microphone, a media *source* (field 5), not a sink. */
private fun micSource(): ByteArray = lenField(
1,
varintField(1, 3L) + lenField(5, varintField(1, 1L) + lenField(2, audioConfig(16_000, 16, 1))),
)
private fun uconnectResponse(): ByteArray =
videoSink() +
micSource() +
audioSink(id = 5, codec = 1, audioType = 3, config = audioConfig(48_000, 16, 2)) +
audioSink(id = 6, codec = 1, audioType = 1, config = audioConfig(16_000, 16, 1)) +
Protobuf.stringField(2, "Uconnect") +
Protobuf.stringField(3, "Dodge Challenger") +
Protobuf.stringField(4, "18")
@Test
fun `finds both of the car's audio sinks and neither the video sink nor the microphone`() {
val sinks = ServiceDiscovery.findAudioServices(uconnectResponse())
assertEquals(listOf(5, 6), sinks.map { it.serviceId })
assertEquals(listOf("MEDIA", "GUIDANCE"), sinks.map { it.streamName })
}
@Test
fun `the media sink is PCM at 48kHz stereo`() {
val media = ServiceDiscovery.findAudioServices(uconnectResponse()).first { it.streamType == 3 }
assertEquals("AUDIO_PCM", media.codecName)
assertEquals(1, media.configs.size)
assertEquals(48_000, media.configs[0].sampleRate)
assertEquals(16, media.configs[0].bits)
assertEquals(2, media.configs[0].channels)
}
@Test
fun `the guidance sink is mono at 16kHz, so it must not be used for music`() {
val guidance = ServiceDiscovery.findAudioServices(uconnectResponse()).first { it.streamType == 1 }
assertEquals(16_000, guidance.configs[0].sampleRate)
assertEquals(1, guidance.configs[0].channels)
}
@Test
fun `the video sink is still found alongside the audio ones`() {
assertNotNull(ServiceDiscovery.findVideoService(uconnectResponse()))
assertEquals(1, ServiceDiscovery.findVideoService(uconnectResponse())!!.serviceId)
}
}
+8
View File
@@ -0,0 +1,8 @@
# Copy to keystore.properties and fill in. That file is gitignored; this one is not, so never
# put real passwords here.
#
# storeFile is resolved from the repository root.
storeFile=gearslip-release.jks
storePassword=
keyAlias=gearslip
keyPassword=
+1
View File
@@ -25,4 +25,5 @@ dependencyResolutionManagement {
rootProject.name = "Gearslip"
include(":gearslip")
// include(":ksdump")