Author SHA1 Message Date
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
46 changed files with 2353 additions and 374 deletions

No files matched your search

+7
View File
@@ -12,7 +12,14 @@ local.properties
# The head unit certificate. Never committed or shared beyond who Seb sends it to directly. # The head unit certificate. Never committed or shared beyond who Seb sends it to directly.
/cert /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. # Local investigation script; not meant for the public repo.
/docs/cert-sweep.sh /docs/cert-sweep.sh
archive/ archive/
CLAUDE.md CLAUDE.md
*.pdf
+32 -32
View File
@@ -4,36 +4,33 @@
Bring any app to your car screen. 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 Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
to a car head unit over USB and acts as a **Car App Library host**: apps that ship an directly to a car head unit over USB, with no Google software in the path. Gearslip acts
Android Auto car UI (navigation and media apps) hand Gearslip a template, and Gearslip as a Car App Library host: an app that ships a car interface hands Gearslip a template,
renders it natively on the car screen, full quality, no phone-screen mirroring. A separate and Gearslip renders that template natively on the car screen. Nothing is mirrored from
screen-sharing mode is included for apps that don't ship a car UI at all. 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 The author reverse-engineered the protocol for interoperability, so that a client other
talking to a car head unit that only expects Google's own app. than Google's own can talk to a car head unit.
## Status ## Status
Real progress, with one hard limit that's worth knowing before you build on this: - **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
- **Templated app hosting works.** All 16 template types the Car App Library defines are tested against several real navigation and media apps.
rendered, including a keyboard Gearslip draws itself for search and sign-in. Verified - **Media playback works.** Gearslip lets you browse and control car-enabled media apps
against several real navigation and media apps. from the car screen, with lyrics and playback state, over the same host connection.
- **Media playback works.** Car-enabled media apps are browsable and controllable from the - **A real head unit needs a certificate that Google controls.** A head unit accepts only
car UI, with lyrics and playback state, over the same host connection. a phone identity that chains to Google's Automotive Link certificate authority, and
- **The connection to a real head unit needs a certificate Google controls, and Gearslip Google alone issues those certificates. Gearslip does not ship one. Without it,
doesn't ship one.** A head unit only accepts a phone identity that chains to Google's Gearslip runs and renders correctly against a debug harness, but a real car rejects the
Automotive Link certificate authority - something only Google issues. Without one, Gearslip connection. A leaked certificate exists that older head units accept; newer firmware
still runs and renders everything correctly against a debug harness, but a real car will and Google's reference tooling reject it.
reject the connection outright. See [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) for what was - **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
tested, including one publicly-known leaked certificate that works on some older head Android permission or an on-device consent dialog.
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.
## Building ## Building
@@ -43,14 +40,17 @@ Real progress, with one hard limit that's worth knowing before you build on this
The APK lands at `build/Gearslip-debug.apk`. 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 ## Writing apps for Gearslip
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 [docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
custom head unit client would need. can render: templated apps, media apps, and the Gearslip app protocol.
## License ## License
AGPL-3.0 - see [LICENSE](LICENSE). A separate commercial license is available for private Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
use outside the AGPL's terms; see [NOTICE.md](NOTICE.md). 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.
+32 -2
View File
@@ -1,3 +1,4 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
@@ -13,6 +14,16 @@ plugins {
alias(libs.plugins.kotlin.compose) 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 { android {
namespace = "app.seb3thehacker.gearslip" namespace = "app.seb3thehacker.gearslip"
compileSdk = 37 compileSdk = 37
@@ -21,8 +32,27 @@ android {
applicationId = "app.seb3thehacker.gearslip" applicationId = "app.seb3thehacker.gearslip"
minSdk = 35 minSdk = 35
targetSdk = 37 targetSdk = 37
versionCode = 2 versionCode = 3
versionName = "0.0.02-pre" versionName = "0.0.03-pre"
}
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 // Keeps build output out of the module folder: <root>/build/gearslip/... instead of
@@ -76,6 +76,27 @@ class CarPreviewActivity : ComponentActivity() {
GearslipLog.i("preview: wrote ${out.name}") GearslipLog.i("preview: wrote ${out.name}")
step++ 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 // 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. // outside (adb `am start --display` / `input -d`) while the harness records it.
val snaps = intent.getIntExtra("snaps", 0) val snaps = intent.getIntExtra("snaps", 0)
@@ -89,8 +110,9 @@ class CarPreviewActivity : ComponentActivity() {
snap() snap()
taps.forEachIndexed { i, (x, y) -> taps.forEachIndexed { i, (x, y) ->
main.postDelayed({ main.postDelayed({
p.dispatchTouch(MotionEvent.ACTION_DOWN, x, y) val point = listOf(TouchPoint(0, x, y))
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, x, y) }, 80) p.dispatchTouch(MotionEvent.ACTION_DOWN, 0, point)
main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, 0, point) }, 80)
}, 2500L * (i + 1)) }, 2500L * (i + 1))
main.postDelayed({ snap() }, 2500L * (i + 1) + 1500) main.postDelayed({ snap() }, 2500L * (i + 1) + 1500)
} }
+11
View File
@@ -60,6 +60,17 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Gearslip"> 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 singleTop: the head unit can re-attach while we are already foregrounded, which
arrives via onNewIntent rather than a fresh instance. arrives via onNewIntent rather than a fresh instance.
@@ -8,6 +8,7 @@ object AppSettings {
private const val PREFS = "gearslip_settings" private const val PREFS = "gearslip_settings"
private const val KEY_STARTUP_URL = "startup_url" private const val KEY_STARTUP_URL = "startup_url"
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning" private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
private const val KEY_DEBUG_MODE = "debug_mode"
private fun prefs(context: Context) = private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -34,4 +35,16 @@ object AppSettings {
fun setSeenCompatWarning(context: Context) { fun setSeenCompatWarning(context: Context) {
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply() prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
} }
/**
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
* preview) on the home screen. On by default while the project is pre-release.
*/
fun debugMode(context: Context): Boolean =
prefs(context).getBoolean(KEY_DEBUG_MODE, true)
fun setDebugMode(context: Context, value: Boolean) {
prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply()
GearslipLog.debug = value
}
} }
@@ -27,6 +27,7 @@ import java.security.cert.X509Certificate
* SPIKE_FINDINGS.md). * SPIKE_FINDINGS.md).
*/ */
object CertProvider { object CertProvider {
private val log = GearslipLog.tagged("CERT")
private const val P12_NAME = "phone.p12" private const val P12_NAME = "phone.p12"
private const val PASS_NAME = "phone.pass" private const val PASS_NAME = "phone.pass"
@@ -66,7 +67,7 @@ object CertProvider {
if (imported.isFile) { if (imported.isFile) {
runCatching { runCatching {
return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED) 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 dir = context.getExternalFilesDir(null) val dir = context.getExternalFilesDir(null)
@@ -80,7 +81,7 @@ object CertProvider {
DEFAULT_PASSWORD DEFAULT_PASSWORD
} }
return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED) 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 return null
} }
@@ -103,7 +104,7 @@ object CertProvider {
target.parentFile?.mkdirs() target.parentFile?.mkdirs()
staging.copyTo(target, overwrite = true) staging.copyTo(target, overwrite = true)
importedFile(context, PASS_NAME).writeText(password) importedFile(context, PASS_NAME).writeText(password)
GearslipLog.i("imported a certificate: ${identity.certificate.subjectX500Principal}") log.i("imported a certificate: ${identity.certificate.subjectX500Principal}")
return identity return identity
} finally { } finally {
staging.delete() staging.delete()
@@ -116,7 +117,7 @@ object CertProvider {
fun removeImported(context: Context) { fun removeImported(context: Context) {
importedFile(context, P12_NAME).delete() importedFile(context, P12_NAME).delete()
importedFile(context, PASS_NAME).delete() importedFile(context, PASS_NAME).delete()
GearslipLog.i("removed the imported certificate") log.i("removed the imported certificate")
} }
// The imported files live in the app's private internal storage (not the external files // The imported files live in the app's private internal storage (not the external files
@@ -12,9 +12,10 @@ import java.util.concurrent.TimeUnit
* the TLS self-test: make our own failures loud on the bench. * the TLS self-test: make our own failures loud on the bench.
*/ */
object EncoderSelfTest { object EncoderSelfTest {
private val log = GearslipLog.tagged("VIDEO")
fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean { 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 codecConfig: ByteArray? = null
var frames = 0 var frames = 0
@@ -59,37 +60,37 @@ object EncoderSelfTest {
val csd = codecConfig val csd = codecConfig
when { when {
csd == null -> { 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 false
} }
frames == 0 -> { frames == 0 -> {
GearslipLog.e("encoder self-test FAILED: no frames produced") log.e("encoder self-test FAILED: no frames produced")
false false
} }
!sawKeyFrame -> { !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 false
} }
!sawResyncKeyFrame -> { !sawResyncKeyFrame -> {
GearslipLog.e( log.e(
"encoder self-test FAILED: requestSyncFrame() produced no keyframe - " + "encoder self-test FAILED: requestSyncFrame() produced no keyframe - " +
"the resync-after-drop fix would silently do nothing on this device", "the resync-after-drop fix would silently do nothing on this device",
) )
false false
} }
else -> { else -> {
GearslipLog.i( log.i(
"SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " + "SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " +
"requestSyncFrame() confirmed working", "requestSyncFrame() confirmed working",
) )
GearslipLog.hex(" csd", csd, limit = 48) log.hex(" csd", csd, limit = 48)
GearslipLog.i("=== encoder self-test PASSED ===") log.i("=== encoder self-test PASSED ===")
true true
} }
} }
} catch (t: Throwable) { } catch (t: Throwable) {
runCatching { source.stop() } runCatching { source.stop() }
GearslipLog.e("encoder self-test FAILED with an exception", t) log.e("encoder self-test FAILED with an exception", t)
false false
} }
} }
@@ -26,6 +26,7 @@ import app.seb3thehacker.gearslip.mirror.TouchRelayService
import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarSettings import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.CarUi import app.seb3thehacker.gearslip.car.CarUi
import app.seb3thehacker.gearslip.car.Prefetch
import app.seb3thehacker.gearslip.ui.GearslipApp import app.seb3thehacker.gearslip.ui.GearslipApp
import app.seb3thehacker.gearslip.ui.GearslipTheme import app.seb3thehacker.gearslip.ui.GearslipTheme
import java.io.FileInputStream import java.io.FileInputStream
@@ -96,12 +97,12 @@ class GearslipActivity : ComponentActivity(), Projection {
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */ /** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
private val locationPermission = registerForActivityResult( private val locationPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { } ) { granted -> if (granted) Prefetch.warm(this, force = true) }
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */ /** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
private val calendarPermission = registerForActivityResult( private val calendarPermission = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { } ) { granted -> if (granted) Prefetch.warm(this, force = true) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -115,8 +116,10 @@ class GearslipActivity : ComponentActivity(), Projection {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
GearslipLog.init(this) GearslipLog.init(this)
GearslipLog.installCrashHandler()
GearslipLog.i("Gearslip ready") GearslipLog.i("Gearslip ready")
CarSettings.init(this) CarSettings.init(this)
Prefetch.warm(this)
CarEnvironment.setPhoneTheme(resources.configuration) CarEnvironment.setPhoneTheme(resources.configuration)
setContent { setContent {
@@ -141,6 +144,12 @@ class GearslipActivity : ComponentActivity(), Projection {
CarEnvironment.setPhoneTheme(newConfig) 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) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleIntent(intent) handleIntent(intent)
@@ -268,6 +277,7 @@ class GearslipActivity : ComponentActivity(), Projection {
private fun closeAccessory() { private fun closeAccessory() {
runner?.stop() runner?.stop()
SessionReport.print()
runCatching { descriptor?.close() } runCatching { descriptor?.close() }
descriptor = null descriptor = null
runner = null runner = null
@@ -287,10 +297,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 { runOnUiThread {
if (PhoneMirror.active.value) relayTouch(action, x, y) // Mirroring drives one phone touch at a time, so it follows the first finger only.
else screenProjector?.dispatchTouch(action, x, y) 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 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 // 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 // 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. // snapshot per frame rather than one list copy per line.
@@ -40,10 +47,13 @@ object GearslipLog {
val version: StateFlow<Int> = liveVersion val version: StateFlow<Int> = liveVersion
fun init(context: Context) { fun init(context: Context) {
debug = AppSettings.debugMode(context)
val dir = context.getExternalFilesDir(null) ?: return val dir = context.getExternalFilesDir(null) ?: return
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
file = File(dir, "gearslip-$name.log") file = File(dir, "gearslip-$name.log")
i("log file: ${file?.absolutePath}") i("log file: ${file?.absolutePath}")
SessionReport.init(context)
i("debug mode: $debug")
} }
fun snapshot(): List<String> = synchronized(live) { live.toList() } 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") 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) { fun w(message: String, tag: String = "APP") = write("W", message, tag)
write("E", if (t == null) message else "$message: ${t.javaClass.simpleName}: ${t.message}")
if (t != null) Log.e(TAG, message, t) /** 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. */ /** 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) } 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 shown = minOf(data.size, limit)
val sb = StringBuilder() val sb = StringBuilder()
for (idx in 0 until shown) { for (idx in 0 until shown) {
@@ -97,16 +176,16 @@ object GearslipLog {
if (idx % 16 == 15) sb.append('\n') else sb.append(' ') if (idx % 16 == 15) sb.append('\n') else sb.append(' ')
} }
val suffix = if (data.size > limit) "\n... (${data.size - limit} more bytes)" else "" 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) { when (level) {
"W" -> Log.w(TAG, message) "W" -> Log.w(TAG, message)
"E" -> Log.e(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) { synchronized(live) {
live.addLast(line) live.addLast(line)
while (live.size > MAX_LIVE_LINES) live.removeFirst() while (live.size > MAX_LIVE_LINES) live.removeFirst()
@@ -27,6 +27,7 @@ class GearslipRunner(
private val projection: Projection? = null, private val projection: Projection? = null,
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { 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 } private enum class State { WAIT_VERSION, TLS_HANDSHAKE, WAIT_AUTH, WAIT_SDR, DONE }
@@ -61,16 +62,18 @@ class GearslipRunner(
@Volatile private var lastKeyframeSentAt = 0L @Volatile private var lastKeyframeSentAt = 0L
fun run() { fun run() {
GearslipLog.i("--- spike run starting ---") SessionReport.begin()
log.i("--- session starting ---")
SessionStatus.connecting("Connected to the head unit") 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 identity = identityProvider()
val cert = identity.certificate val cert = identity.certificate
GearslipLog.i("phone certificate source: ${identity.source}") log.i("phone certificate source: ${identity.source}")
GearslipLog.i(" subject = ${cert.subjectX500Principal}") SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
GearslipLog.i(" issuer = ${cert.issuerX500Principal}") log.i(" subject = ${cert.subjectX500Principal}")
GearslipLog.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}") log.i(" issuer = ${cert.issuerX500Principal}")
log.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}")
tls = PhoneTls(identity.keyStore, identity.password) tls = PhoneTls(identity.keyStore, identity.password)
startWatchdog() startWatchdog()
@@ -80,7 +83,7 @@ class GearslipRunner(
while (running) { while (running) {
val read = input.read(chunk) val read = input.read(chunk)
if (read < 0) { 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() reportDisconnect()
return return
} }
@@ -92,11 +95,12 @@ class GearslipRunner(
} }
} }
} catch (t: Throwable) { } 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() reportDisconnect()
} finally { } finally {
running = false running = false
GearslipLog.flush() log.flush()
} }
} }
@@ -113,7 +117,7 @@ class GearslipRunner(
private fun handleFrame(frame: Frames.Frame) { private fun handleFrame(frame: Frames.Frame) {
val payload = if (frame.encrypted) { val payload = if (frame.encrypted) {
if (!tls.handshakeComplete) { if (!tls.handshakeComplete) {
GearslipLog.w("encrypted frame arrived before the handshake finished - ignoring") log.w("encrypted frame arrived before the handshake finished - ignoring")
return return
} }
tls.decrypt(frame.payload) tls.decrypt(frame.payload)
@@ -123,7 +127,7 @@ class GearslipRunner(
val message = assembler.offer(frame, payload) ?: return val message = assembler.offer(frame, payload) ?: return
if (message.size < 2) { if (message.size < 2) {
GearslipLog.w("runt message on channel ${frame.channel}") log.w("runt message on channel ${frame.channel}")
return return
} }
@@ -140,7 +144,7 @@ class GearslipRunner(
} else if (frame.channel == audioLink?.channelId) { } else if (frame.channel == audioLink?.channelId) {
audioLink?.onMessage(messageId, body) audioLink?.onMessage(messageId, body)
} else { } else {
GearslipLog.i("ignoring message id $messageId on channel ${frame.channel}") log.i("ignoring message id $messageId on channel ${frame.channel}")
} }
return return
} }
@@ -152,18 +156,19 @@ class GearslipRunner(
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body) MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
MSG_PING_REQUEST -> onPingRequest(body) MSG_PING_REQUEST -> onPingRequest(body)
AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0) 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) { private fun onVersionRequest(body: ByteArray) {
if (body.size < 4) { if (body.size < 4) {
GearslipLog.w("VersionRequest too short (${body.size} bytes)") log.w("VersionRequest too short (${body.size} bytes)")
return return
} }
val major = ((body[0].toInt() and 0xFF) shl 8) or (body[1].toInt() and 0xFF) 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) 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 // 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, // asserting 1.6 is the permissive choice: we only need to reach service discovery,
@@ -174,19 +179,20 @@ class GearslipRunner(
0, 0, // STATUS_SUCCESS 0, 0, // STATUS_SUCCESS
) )
send(MSG_VERSION_RESPONSE, response, encrypted = false) send(MSG_VERSION_RESPONSE, response, encrypted = false)
GearslipLog.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)") log.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
state = State.TLS_HANDSHAKE state = State.TLS_HANDSHAKE
SessionStatus.connecting("Securing the link") 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) { private fun onHandshake(body: ByteArray) {
GearslipLog.i("<- EncapsulatedSSL (${body.size} bytes)") log.i("<- EncapsulatedSSL (${body.size} bytes)")
val replies = try { val replies = try {
tls.pumpHandshake(body) tls.pumpHandshake(body)
} catch (t: Throwable) { } catch (t: Throwable) {
GearslipLog.e("TLS handshake threw", t) log.e("TLS handshake threw", t)
GearslipLog.verdict( SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
log.verdict(
"NOT VIABLE (TLS rejected)", "NOT VIABLE (TLS rejected)",
"The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " + "The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " +
"handshake_failure alert here means it validates the phone's certificate.", "handshake_failure alert here means it validates the phone's certificate.",
@@ -196,31 +202,34 @@ class GearslipRunner(
"The head unit aborted the secure handshake. It validates the phone's certificate.", "The head unit aborted the secure handshake. It validates the phone's certificate.",
) )
state = State.DONE state = State.DONE
SessionReport.print()
return return
} }
replies.forEach { replies.forEach {
send(MSG_ENCAPSULATED_SSL, it, encrypted = false) 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) { if (tls.handshakeComplete && state == State.TLS_HANDSHAKE) {
state = State.WAIT_AUTH 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) { private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1) val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("<- AuthComplete: status=$status") log.i("<- AuthComplete: status=$status")
status?.let { SessionReport.auth(it) }
when (status) { when (status) {
0 -> { 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 state = State.WAIT_SDR
SessionStatus.connecting("Certificate accepted - discovering services") SessionStatus.connecting("Certificate accepted - discovering services")
sendServiceDiscoveryRequest() sendServiceDiscoveryRequest()
} }
-2 -> { -2 -> {
GearslipLog.verdict( SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name)
log.verdict(
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)", "NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
"The head unit explicitly rejected the phone's self-signed certificate. " + "The head unit explicitly rejected the phone's self-signed certificate. " +
"This is a definitive no - it validates the phone against a trust chain " + "This is a definitive no - it validates the phone against a trust chain " +
@@ -233,7 +242,8 @@ class GearslipRunner(
state = State.DONE state = State.DONE
} }
-3 -> { -3 -> {
GearslipLog.verdict( SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name)
log.verdict(
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)", "NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
"Authentication rejected. Broader than a pure certificate error, but the " + "Authentication rejected. Broader than a pure certificate error, but the " +
"practical answer is the same.", "practical answer is the same.",
@@ -245,7 +255,8 @@ class GearslipRunner(
state = State.DONE state = State.DONE
} }
else -> { else -> {
GearslipLog.verdict( SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name)
log.verdict(
"INCONCLUSIVE (AuthComplete status=$status)", "INCONCLUSIVE (AuthComplete status=$status)",
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " + "Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
"before drawing any conclusion.", "before drawing any conclusion.",
@@ -260,13 +271,13 @@ class GearslipRunner(
val body = Protobuf.stringField(4, "Gearslip") + // label_text val body = Protobuf.stringField(4, "Gearslip") + // label_text
Protobuf.stringField(5, Build.MODEL) // device_name Protobuf.stringField(5, Build.MODEL) // device_name
send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true) send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true)
GearslipLog.i("-> ServiceDiscoveryRequest (encrypted)") log.i("-> ServiceDiscoveryRequest (encrypted)")
} }
private fun onServiceDiscoveryResponse(body: ByteArray) { private fun onServiceDiscoveryResponse(body: ByteArray) {
GearslipLog.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)") log.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)")
GearslipLog.i("head unit describes itself as:\n" + Protobuf.describe(body)) log.i("head unit describes itself as:\n" + Protobuf.describe(body))
GearslipLog.verdict( log.verdict(
"VIABLE", "VIABLE",
"The head unit accepted the presented phone certificate, completed TLS, and is " + "The head unit accepted the presented phone certificate, completed TLS, and is " +
"advertising its channels over the encrypted session. A phone-side client is " + "advertising its channels over the encrypted session. A phone-side client is " +
@@ -274,13 +285,15 @@ class GearslipRunner(
) )
state = State.DONE state = State.DONE
val info = ServiceDiscovery.findHeadUnitInfo(body) val info = ServiceDiscovery.findHeadUnitInfo(body)
GearslipLog.i("head unit: $info") log.i("head unit: $info")
SessionReport.headUnit(info.toString())
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull() vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
val profile = vehicleProfile val profile = vehicleProfile
SessionReport.profile(profile?.name)
if (profile != null) { 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 { } 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) CarEnvironment.setVehicle(profile?.name ?: "Unknown vehicle", profile?.insets ?: Insets.NONE)
SessionStatus.connecting("Starting video") SessionStatus.connecting("Starting video")
@@ -303,18 +316,19 @@ class GearslipRunner(
private fun startVideoChannel(serviceDiscoveryResponse: ByteArray) { private fun startVideoChannel(serviceDiscoveryResponse: ByteArray) {
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse) val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
if (video == null) { 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 return
} }
videoChannelId = video.serviceId videoChannelId = video.serviceId
videoConfigs = video.configs 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 -> video.configs.forEachIndexed { index, config ->
GearslipLog.i(" config[$index] = $config") log.i(" config[$index] = $config")
} }
if (video.codecType != CODEC_H264_BP) { 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) openChannel(video.serviceId)
@@ -322,12 +336,12 @@ class GearslipRunner(
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse)
if (input == null) { 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 { } else {
inputChannelId = input.serviceId inputChannelId = input.serviceId
touchWidth = input.touchWidth touchWidth = input.touchWidth
touchHeight = input.touchHeight touchHeight = input.touchHeight
GearslipLog.i( log.i(
"input service: channel=${input.serviceId} touchscreen=" + "input service: channel=${input.serviceId} touchscreen=" +
"${input.touchWidth}x${input.touchHeight}", "${input.touchWidth}x${input.touchHeight}",
) )
@@ -339,13 +353,13 @@ class GearslipRunner(
private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) { private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) {
val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse) val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse)
sinks.forEach { sink -> sinks.forEach { sink ->
GearslipLog.i( log.i(
"audio sink: channel=${sink.serviceId} stream=${sink.streamName} codec=${sink.codecName} " + "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}" }, sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" },
) )
} }
val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run { val media = 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")
return return
} }
audioLink = AudioLink( audioLink = AudioLink(
@@ -363,7 +377,7 @@ class GearslipRunner(
MSG_CHANNEL_OPEN_REQUEST, request, encrypted = true, MSG_CHANNEL_OPEN_REQUEST, request, encrypted = true,
channel = serviceId, messageType = Frames.MESSAGE_CONTROL, 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 +396,42 @@ class GearslipRunner(
private fun onInputMessage(messageId: Int, body: ByteArray) { private fun onInputMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> MSG_CHANNEL_OPEN_RESPONSE ->
GearslipLog.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}") log.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}")
MSG_INPUT_REPORT -> { MSG_INPUT_REPORT -> {
val report = Wire.fields(body) val report = Wire.fields(body)
val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7) val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7)
if (touch == null) { if (touch == null) {
GearslipLog.i("<- InputReport with no touch payload (key or rotary event)") log.i("<- InputReport with no touch payload (key or rotary event)")
return return
} }
val touchFields = Wire.fields(touch) val touchFields = Wire.fields(touch)
val action = Wire.varint(touchFields, 3)?.toInt() ?: 0 val action = Wire.varint(touchFields, 3)?.toInt() ?: 0
val pointers = Wire.allBytes(touchFields, 1) val pointers = Wire.allBytes(touchFields, 1)
if (pointers.isEmpty()) return 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 size = videoConfigs.getOrNull(selectedConfigIndex ?: 0)?.pixelSize()
val x = clamp(rawX, size?.first) val points = pointers.mapIndexedNotNull { index, pointer ->
val y = clamp(rawY, size?.second) 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++ touchesSeen++
if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) { if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) {
val note = if (x != rawX.toFloat() || y != rawY.toFloat()) " (clamped)" else "" val at = points[actionIndex]
GearslipLog.i("<- Touch action=$action at ($rawX,$rawY)$note") 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 +439,9 @@ class GearslipRunner(
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> { MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) val status = Protobuf.readInt32Field(body, 1)
GearslipLog.i("<- ChannelOpenResponse: status=$status") log.i("<- ChannelOpenResponse: status=$status")
if (status != 0) { if (status != 0) {
GearslipLog.verdict( log.verdict(
"PHASE A FAILED (channel open rejected)", "PHASE A FAILED (channel open rejected)",
"The head unit refused to open the video channel (status=$status).", "The head unit refused to open the video channel (status=$status).",
) )
@@ -435,7 +451,7 @@ class GearslipRunner(
// Setup { required MediaCodecType type = 1 } // Setup { required MediaCodecType type = 1 }
send(MSG_MEDIA_SETUP, Protobuf.varintField(1, CODEC_H264_BP.toLong()), send(MSG_MEDIA_SETUP, Protobuf.varintField(1, CODEC_H264_BP.toLong()),
encrypted = true, channel = videoChannelId) encrypted = true, channel = videoChannelId)
GearslipLog.i("-> Setup(codec=VIDEO_H264_BP)") log.i("-> Setup(codec=VIDEO_H264_BP)")
} }
MSG_MEDIA_CONFIG -> { MSG_MEDIA_CONFIG -> {
@@ -443,7 +459,7 @@ class GearslipRunner(
val status = Wire.varint(fields, 1)?.toInt() val status = Wire.varint(fields, 1)?.toInt()
val maxUnackedFromConfig = Wire.varint(fields, 2)?.toInt() val maxUnackedFromConfig = Wire.varint(fields, 2)?.toInt()
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint } 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 " + "<- Config: status=$status (1=WAIT 2=READY) max_unacked=$maxUnackedFromConfig " +
"configuration_indices=$indices", "configuration_indices=$indices",
) )
@@ -457,21 +473,25 @@ class GearslipRunner(
selectedConfigIndex = preferred ?: offered.minByOrNull { idx -> selectedConfigIndex = preferred ?: offered.minByOrNull { idx ->
videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE
} ?: offered.firstOrNull() } ?: 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 } // VideoFocusRequestNotification { mode = 2; reason = 3 }
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) + val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
Protobuf.varintField(3, 1L) Protobuf.varintField(3, 1L)
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId) send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> VideoFocusRequest(mode=PROJECTED)") log.i("-> VideoFocusRequest(mode=PROJECTED)")
} }
MSG_VIDEO_FOCUS_NOTIFICATION -> { MSG_VIDEO_FOCUS_NOTIFICATION -> {
val fields = Wire.fields(body) 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. // The head unit repeats this while it waits for frames; only act once.
if (!videoStarted) { if (!videoStarted) {
videoStarted = true 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() startVideoSource()
} }
} }
@@ -483,11 +503,11 @@ class GearslipRunner(
acksSeen++ acksSeen++
lastAckAt = System.currentTimeMillis() lastAckAt = System.currentTimeMillis()
if (framesSent <= 3 || framesSent % 60 == 0) { 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 +523,18 @@ class GearslipRunner(
val index = selectedConfigIndex ?: 0 val index = selectedConfigIndex ?: 0
val config = videoConfigs.getOrNull(index) val config = videoConfigs.getOrNull(index)
if (config == null) { 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 return
} }
val (width, height) = config.pixelSize() ?: run { val (width, height) = config.pixelSize() ?: run {
GearslipLog.e("unsupported resolution ${config.resolutionName}") log.e("unsupported resolution ${config.resolutionName}")
return return
} }
val start = Protobuf.varintField(1, SESSION_ID.toLong()) + val start = Protobuf.varintField(1, SESSION_ID.toLong()) +
Protobuf.varintField(2, index.toLong()) Protobuf.varintField(2, index.toLong())
send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId) send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId)
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 val useProjection = projection != null
videoSource = VideoSource( videoSource = VideoSource(
@@ -524,8 +544,8 @@ class GearslipRunner(
mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD, mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD,
onCodecConfig = { csd -> onCodecConfig = { csd ->
send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId) send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId)
GearslipLog.i("-> CodecConfig (${csd.size} bytes SPS/PPS)") log.i("-> CodecConfig (${csd.size} bytes SPS/PPS)")
GearslipLog.hex(" csd", csd, limit = 64) log.hex(" csd", csd, limit = 64)
}, },
onFrame = { data, presentationTimeUs, keyFrame -> onFrame = { data, presentationTimeUs, keyFrame ->
sendVideoFrame(data, presentationTimeUs, keyFrame) sendVideoFrame(data, presentationTimeUs, keyFrame)
@@ -538,7 +558,7 @@ class GearslipRunner(
projection.onSurfaceReady(surface, width, height, config.density) projection.onSurfaceReady(surface, width, height, config.density)
SessionStatus.projecting() SessionStatus.projecting()
} }
}.onFailure { e -> GearslipLog.e("encoder start failed", e) } }.onFailure { e -> log.e("encoder start failed", e) }
} }
} }
@@ -549,12 +569,12 @@ class GearslipRunner(
// looks identical to a frozen picture. Assume the window cleared and carry on. // looks identical to a frozen picture. Assume the window cleared and carry on.
val since = System.currentTimeMillis() - lastAckAt val since = System.currentTimeMillis() - lastAckAt
if (since > ACK_STALL_MS) { 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 inFlight = 0
} else { } else {
dropped++ dropped++
if (dropped % 30 == 1) { 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 - // 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. // every frame after it is a P-frame assuming a picture that never arrived.
@@ -581,7 +601,7 @@ class GearslipRunner(
} }
if (keyFrame) { if (keyFrame) {
if (resyncRequested) { if (resyncRequested) {
GearslipLog.i("resync keyframe delivered after $resyncAttempts attempt(s)") log.i("resync keyframe delivered after $resyncAttempts attempt(s)")
} }
resyncRequested = false resyncRequested = false
resyncAttempts = 0 resyncAttempts = 0
@@ -590,7 +610,7 @@ class GearslipRunner(
try { try {
send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId) send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId)
} catch (e: java.io.IOException) { } 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 running = false
videoSource?.stop() videoSource?.stop()
return return
@@ -598,7 +618,7 @@ class GearslipRunner(
inFlight++ inFlight++
framesSent++ framesSent++
if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) { if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) {
GearslipLog.i( log.i(
"-> Data frame #$framesSent (${data.size} bytes, keyFrame=$keyFrame, " + "-> Data frame #$framesSent (${data.size} bytes, keyFrame=$keyFrame, " +
"ptsUs=$presentationTimeUs)", "ptsUs=$presentationTimeUs)",
) )
@@ -615,7 +635,7 @@ class GearslipRunner(
private fun onPingRequest(body: ByteArray) { private fun onPingRequest(body: ByteArray) {
val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime() val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime()
send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete) 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( private fun send(
@@ -662,6 +682,13 @@ class GearslipRunner(
} }
private fun reportDisconnect() { 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) { when (state) {
State.DONE -> SessionStatus.disconnected() State.DONE -> SessionStatus.disconnected()
State.WAIT_VERSION -> SessionStatus.failed( State.WAIT_VERSION -> SessionStatus.failed(
@@ -678,28 +705,29 @@ class GearslipRunner(
) )
} }
when (state) { when (state) {
State.WAIT_VERSION -> GearslipLog.verdict( State.WAIT_VERSION -> log.verdict(
"INCONCLUSIVE (no VersionRequest)", "INCONCLUSIVE (no VersionRequest)",
"The head unit never opened the control channel. This is a connection problem, " + "The head unit never opened the control channel. This is a connection problem, " +
"not a certificate verdict - check that the accessory was actually claimed.", "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)", "LIKELY NOT VIABLE (dropped during TLS)",
"The unit disconnected mid-handshake without sending an alert we could read. " + "The unit disconnected mid-handshake without sending an alert we could read. " +
"Rejection is the likeliest reading, but re-run once before trusting it.", "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)", "INCONCLUSIVE (dropped after TLS, before AuthComplete)",
"TLS completed but the unit disconnected before stating a verdict. Re-run.", "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)", "INCONCLUSIVE (accepted, then dropped before service discovery)",
"AuthComplete said success, so the certificate was accepted - but our " + "AuthComplete said success, so the certificate was accepted - but our " +
"ServiceDiscoveryRequest drew no reply. Suspect our own encryption or " + "ServiceDiscoveryRequest drew no reply. Suspect our own encryption or " +
"framing rather than the head unit.", "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() { private fun startWatchdog() {
@@ -711,7 +739,7 @@ class GearslipRunner(
if (state != State.DONE) { if (state != State.DONE) {
val idle = System.currentTimeMillis() - lastProgress 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 // Stream health. Without this a drive cannot tell "streaming fine" from
@@ -720,7 +748,8 @@ class GearslipRunner(
val sent = framesSent val sent = framesSent
val fps = (sent - lastFrames) / 5.0 val fps = (sent - lastFrames) / 5.0
lastFrames = sent 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 " + "video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " +
"acked=$acksSeen inFlight=$inFlight split=$splitMessages", "acked=$acksSeen inFlight=$inFlight split=$splitMessages",
) )
@@ -29,6 +29,7 @@ import javax.net.ssl.X509ExtendedTrustManager
* the right tool. * the right tool.
*/ */
class PhoneTls(keyStore: KeyStore, password: CharArray) { class PhoneTls(keyStore: KeyStore, password: CharArray) {
private val log = GearslipLog.tagged("TLS")
private val engine: SSLEngine private val engine: SSLEngine
private var netIn = ByteArray(0) private var netIn = ByteArray(0)
@@ -156,7 +157,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
continue continue
} }
SSLEngineResult.Status.BUFFER_UNDERFLOW -> { 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 break
} }
SSLEngineResult.Status.CLOSED -> break SSLEngineResult.Status.CLOSED -> break
@@ -173,26 +174,29 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
private fun describeSession() { private fun describeSession() {
val session = engine.session 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 { try {
val peers = session.peerCertificates 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 -> peers.forEachIndexed { index, certificate ->
if (certificate is X509Certificate) { if (certificate is X509Certificate) {
GearslipLog.i(" [$index] subject=${certificate.subjectX500Principal}") log.i(" [$index] subject=${certificate.subjectX500Principal}")
GearslipLog.i(" issuer =${certificate.issuerX500Principal}") log.i(" issuer =${certificate.issuerX500Principal}")
GearslipLog.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}") log.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}")
} }
} }
} catch (_: SSLPeerUnverifiedException) { } 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. */ /** Accepts everything and reports what it saw. We want the data, not a verdict. */
private class LoggingTrustManager : X509ExtendedTrustManager() { private class LoggingTrustManager : X509ExtendedTrustManager() {
private val log = GearslipLog.tagged("TLS")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { 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?) = 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 * 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 * pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
* test page.
*/ */
fun resolve(context: Context): String { fun resolve(context: Context): String {
val saved = AppSettings.startupUrl(context) val saved = AppSettings.startupUrl(context)
@@ -26,9 +25,12 @@ object ProjectedContent {
return url 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. */ /** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
private fun normalize(value: String): String = private fun normalize(value: String): String =
if (value.startsWith("<") || "://" in value) value else "https://$value" 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. * A touch from the head unit, already scaled into projected-video coordinates.
* [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses * [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() 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 val target = root ?: return
if (points.isEmpty()) return
val now = SystemClock.uptimeMillis() val now = SystemClock.uptimeMillis()
if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now 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 { try {
target.dispatchTouchEvent(event) target.dispatchTouchEvent(event)
} finally { } finally {
@@ -0,0 +1,129 @@
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 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 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("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. * unit, not about this code.
*/ */
object TlsSelfTest { object TlsSelfTest {
private val log = GearslipLog.tagged("TLS")
fun run(identity: CertProvider.Identity): Boolean { fun run(identity: CertProvider.Identity): Boolean {
GearslipLog.i("=== TLS self-test starting ===") log.i("=== TLS self-test starting ===")
GearslipLog.i("cert source: ${identity.source}") log.i("cert source: ${identity.source}")
GearslipLog.i(" subject = ${identity.certificate.subjectX500Principal}") log.i(" subject = ${identity.certificate.subjectX500Principal}")
GearslipLog.i(" issuer = ${identity.certificate.issuerX500Principal}") log.i(" issuer = ${identity.certificate.issuerX500Principal}")
return try { return try {
val server = PhoneTls(identity.keyStore, identity.password) val server = PhoneTls(identity.keyStore, identity.password)
val client = Driver(clientEngine()) val client = Driver(clientEngine())
@@ -44,7 +45,7 @@ object TlsSelfTest {
} }
if (!server.handshakeComplete) { 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 return false
} }
@@ -53,14 +54,14 @@ object TlsSelfTest {
val plaintext = "gearslip round trip".toByteArray() val plaintext = "gearslip round trip".toByteArray()
val decrypted = client.decrypt(server.encrypt(plaintext)) val decrypted = client.decrypt(server.encrypt(plaintext))
if (!decrypted.contentEquals(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 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 true
} catch (t: Throwable) { } catch (t: Throwable) {
GearslipLog.e("self-test FAILED with an exception", t) log.e("self-test FAILED with an exception", t)
false false
} }
} }
@@ -26,6 +26,7 @@ class VideoSource(
private val onCodecConfig: (ByteArray) -> Unit, private val onCodecConfig: (ByteArray) -> Unit,
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> 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 * TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for
@@ -67,11 +68,11 @@ class VideoSource(
codec = encoder codec = encoder
running = true 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 { worker = Thread {
runCatching { pump(encoder) } 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() } }.apply { name = "gearslip-encoder"; start() }
} }
@@ -89,7 +90,7 @@ class VideoSource(
codec?.setParameters(Bundle().apply { codec?.setParameters(Bundle().apply {
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
}) })
}.onFailure { GearslipLog.w("requestSyncFrame failed: ${it.message}") } }.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
} }
fun stop() { fun stop() {
@@ -19,7 +19,10 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.foundation.clickable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -63,25 +66,37 @@ fun CarDashboardScreen() {
@Composable @Composable
private fun WeatherCard(context: Context) { private fun WeatherCard(context: Context) {
val weather by produceState<Weather?>(Weather.LOADING) { val state by Weather.state.collectAsState()
value = withContext(Dispatchers.IO) { fetchWeather(context) } val navigator = LocalCarNavigator.current
} LaunchedEffect(Unit) { Weather.refresh(context) }
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { Surface(
Row(Modifier.fillMaxWidth().padding(20.dp), verticalAlignment = Alignment.CenterVertically) { color = MaterialTheme.colorScheme.surfaceContainerHigh,
val w = weather shape = MaterialTheme.shapes.extraLarge,
when { modifier = Modifier.clickable { navigator.weather() },
w === Weather.LOADING -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge) ) {
w == null -> Text( Row(
"Weather unavailable - no location fix yet.", Modifier.fillMaxWidth().padding(20.dp),
style = MaterialTheme.typography.bodyLarge, 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, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
else -> Text( is WeatherState.Ready -> {
"${w.tempC.toInt()}°C · ${w.description}", val w = s.data
style = MaterialTheme.typography.titleLarge, WeatherIcon(w.code, w.isDay, size = 44.dp)
fontWeight = FontWeight.SemiBold, 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 @Composable
private fun AgendaCard(context: Context) { private fun AgendaCard(context: Context) {
val events by produceState<List<AgendaEvent>?>(null) { val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
value = withContext(Dispatchers.IO) { fetchAgenda(context) } value = withContext(Dispatchers.IO) { fetchAgenda(context) }.also { cachedAgenda = it }
} }
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { 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 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 data class AgendaEvent(val title: String, val startMillis: Long, val allDay: Boolean)
private const val WEATHER_TTL_MS = 60 * 60 * 1000L @Volatile private var cachedAgenda: List<AgendaEvent>? = null
private var cachedWeather: Weather? = null
private var cachedWeatherAt = 0L
/** Served from memory for up to an hour: weather barely changes, and each call is a network request. */ /** Loads the weather and the agenda ahead of time, so the dashboard opens with both in place. */
private fun fetchWeather(context: Context): Weather? { fun warmDashboard(context: Context) {
val now = System.currentTimeMillis() Weather.refresh(context, force = true)
cachedWeather?.let { if (now - cachedWeatherAt < WEATHER_TTL_MS) return it } cachedAgenda = fetchAgenda(context)
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 -> "-"
} }
/** /**
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -12,9 +13,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -49,7 +52,7 @@ import kotlinx.coroutines.delay
* The home screen: the map on the left, what is playing on the right. * The home screen: the map on the left, what is playing on the right.
* *
* The map is whatever navigation app was used last, brought back on its own. The player only * 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. * takes its share of the screen only while something is playing or paused; otherwise the map has all of it.
*/ */
@Composable @Composable
fun CarHome() { fun CarHome() {
@@ -61,7 +64,10 @@ fun CarHome() {
LaunchedEffect(Unit) { CarServices.autostart(frame) } LaunchedEffect(Unit) { CarServices.autostart(frame) }
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() } LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
val showPlayer = phase == CarMedia.Phase.READY && now.hasTrack val navigator = LocalCarNavigator.current
val showPlayer = phase == CarMedia.Phase.READY && now.isActive && !navigator.playerMinimised
// Once playback ends there is nothing left to minimise; the next song starts in place.
LaunchedEffect(now.isActive) { if (!now.isActive) navigator.restorePlayer() }
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box( Box(
@@ -136,7 +142,7 @@ private fun MiniPlayer(modifier: Modifier) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
Surface( Surface(
onClick = navigator::media, onClick = { navigator.media() },
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(EMBEDDED_RADIUS), shape = RoundedCornerShape(EMBEDDED_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
@@ -146,7 +152,7 @@ private fun MiniPlayer(modifier: Modifier) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Box(Modifier.size(140.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) { Box(Modifier.size(128.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
} }
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
@@ -190,6 +196,21 @@ private fun MiniPlayer(modifier: Modifier) {
Icon(MediaIcons.Next, "Next", Modifier.size(30.dp)) Icon(MediaIcons.Next, "Next", Modifier.size(30.dp))
} }
} }
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val compact = PaddingValues(horizontal = 8.dp)
FilledTonalButton(
onClick = { navigator.media(lyrics = true) },
modifier = Modifier.weight(1f),
contentPadding = compact,
) { Text("Lyrics", maxLines = 1, softWrap = false) }
FilledTonalButton(
onClick = navigator::minimisePlayer,
modifier = Modifier.weight(1f),
contentPadding = compact,
) { Text("Minimise", maxLines = 1, softWrap = false) }
}
} }
} }
} }
@@ -36,6 +36,16 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import android.content.Context 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 app.seb3thehacker.gearslip.host.KnownApps
import app.seb3thehacker.gearslip.host.CarAppCatalog import app.seb3thehacker.gearslip.host.CarAppCatalog
import app.seb3thehacker.gearslip.host.TemplateApp import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.media.MediaApp import app.seb3thehacker.gearslip.media.MediaApp
@@ -48,6 +58,8 @@ private class Tile(
val label: String, val label: String,
val icon: ImageBitmap? = null, val icon: ImageBitmap? = null,
val glyph: ImageVector? = null, val glyph: ImageVector? = null,
/** Tested from start to finish with Gearslip; drawn with a check mark. */
val verified: Boolean = false,
val onClick: () -> Unit, val onClick: () -> Unit,
) )
@@ -61,15 +73,16 @@ private class Entry(
/** /**
* The installed car and media apps with their icons. Scanning the package manager and decoding * The installed car and media apps with their icons. Scanning the package manager and decoding
* icons is slow, so it happens once per car session; [invalidate] is called when one starts, which * icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
* is the only time new installs are picked up. * which is when new installs are picked up.
*/ */
private object LauncherCache { private object LauncherCache {
@Volatile var entries: List<Entry>? = null @Volatile var entries: List<Entry>? = null
fun invalidate() { entries = null } /** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
@Synchronized
fun load(context: Context): List<Entry> { fun load(context: Context, force: Boolean = false): List<Entry> {
if (!force) entries?.let { return it }
fun iconOf(pkg: String) = runCatching { fun iconOf(pkg: String) = runCatching {
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap() context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
}.getOrNull() }.getOrNull()
@@ -80,8 +93,8 @@ private object LauncherCache {
} }
} }
/** Forgets the scanned apps so the next launcher open rescans; call at the start of a car session. */ /** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
fun invalidateLauncherApps() = LauncherCache.invalidate() fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */ /** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
@Composable @Composable
@@ -90,7 +103,7 @@ fun CarLauncher() {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val frame by CarEnvironment.frame.collectAsState() 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) { val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) } value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
} }
@@ -99,7 +112,8 @@ fun CarLauncher() {
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") }, Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") }, Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
) + installed.map { entry -> ) + installed.map { entry ->
Tile(entry.label, entry.icon) { val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
Tile(entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg)) {
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() } entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
entry.media?.let { CarServices.openMedia(it); navigator.media() } entry.media?.let { CarServices.openMedia(it); navigator.media() }
} }
@@ -115,6 +129,38 @@ fun CarLauncher() {
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(tiles) { AppTile(it) } items(tiles) { AppTile(it) }
if (tiles.any { it.verified }) {
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))
} }
} }
@@ -127,6 +173,7 @@ private fun AppTile(tile: Tile) {
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant, contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
) { ) {
Box(Modifier.fillMaxSize()) {
Column( Column(
Modifier.fillMaxSize().padding(8.dp), Modifier.fillMaxSize().padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -146,5 +193,9 @@ private fun AppTile(tile: Tile) {
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (tile.verified) {
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
}
}
} }
} }
@@ -13,6 +13,9 @@ sealed interface CarScreen {
/** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */ /** Opened from the clock: the day's calendar and the weather, nothing that needs a keyboard. */
data object Dashboard : CarScreen 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. */ /** The current media app, full size: browse, lyrics and controls. */
data object Media : CarScreen data object Media : CarScreen
@@ -37,7 +40,26 @@ class CarNavigator(startId: String) {
fun home() { current = CarScreen.Home } fun home() { current = CarScreen.Home }
fun media() { current = CarScreen.Media } /**
* Minimised, the home screen's player folds into the nav bar and the map takes the width.
* Opening the media screen from the bar restores it, so leaving that screen lands on the
* home screen with the player back in place.
*/
var playerMinimised by mutableStateOf(false)
private set
/** Set by the player's Lyrics button; the media screen reads it once when it opens. */
var lyricsRequested = false
fun minimisePlayer() { playerMinimised = true }
fun restorePlayer() { playerMinimised = false }
fun media(lyrics: Boolean = false) {
lyricsRequested = lyrics
playerMinimised = false
current = CarScreen.Media
}
fun apps() { current = CarScreen.Apps } fun apps() { current = CarScreen.Apps }
@@ -45,6 +67,8 @@ class CarNavigator(startId: String) {
fun dashboard() { current = CarScreen.Dashboard } fun dashboard() { current = CarScreen.Dashboard }
fun weather() { current = CarScreen.Weather }
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) } fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
fun open(id: String) { current = CarScreen.App(id) } fun open(id: String) { current = CarScreen.App(id) }
@@ -163,7 +163,7 @@ private fun WebPageSetting() {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( 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, style = MaterialTheme.typography.bodyLarge,
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
@@ -42,6 +42,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@@ -81,7 +83,14 @@ fun CarUi() {
val base = LocalDensity.current val base = LocalDensity.current
val insets by CarEnvironment.insets.collectAsState() val insets by CarEnvironment.insets.collectAsState()
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) } 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() } } DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// The map follows the light outside, whatever the app's own theme is set to. // The map follows the light outside, whatever the app's own theme is set to.
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() } LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
@@ -90,9 +99,23 @@ fun CarUi() {
LocalDensity provides Density(base.density * scale, base.fontScale), LocalDensity provides Density(base.density * scale, base.fontScale),
LocalCarNavigator provides navigator, LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside, LocalDarkOutside provides darkOutside,
LocalMapControlsWake provides controlsWake,
) { ) {
MaterialTheme(colorScheme = colors) { 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. // Insets are in video-frame pixels, so convert with the un-scaled density.
val pad = with(base) { val pad = with(base) {
PaddingValues( PaddingValues(
@@ -113,6 +136,7 @@ fun CarUi() {
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } } CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
CarScreen.Settings -> CarSettingsScreen() CarScreen.Settings -> CarSettingsScreen()
CarScreen.Dashboard -> CarDashboardScreen() CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke() is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
} }
@@ -140,26 +164,36 @@ private fun CarNavBar(navigator: CarNavigator) {
val darkOutside by CarEnvironment.darkOutside.collectAsState() val darkOutside by CarEnvironment.darkOutside.collectAsState()
val now by rememberNow() val now by rememberNow()
// The home and media screens already show what's playing; everywhere else it lives in the // The home screen shows what's playing itself, unless it was minimised; everywhere else it lives in this bar.
// middle of this bar so it stays one tap away. val showNowPlaying = navigator.current != CarScreen.Home || navigator.playerMinimised
val showNowPlaying = navigator.current != CarScreen.Home && navigator.current != CarScreen.Media val weather by Weather.state.collectAsState()
// One row, so nothing can sit on top of anything else: the player takes whatever width the
// fixed items leave and shortens its title to fit.
Surface(color = MaterialTheme.colorScheme.surfaceContainer) { Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
Box(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) { Row(
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) { Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() } NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() } NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.weight(1f)) Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
if (showNowPlaying) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
}
Column( (weather as? WeatherState.Ready)?.data?.let { w ->
horizontalAlignment = Alignment.End, NavChip(navigator.current == CarScreen.Weather, { navigator.weather() }) {
modifier = Modifier Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
.clip(RoundedCornerShape(16.dp)) WeatherIcon(w.code, w.isDay, size = 28.dp)
.clickable { navigator.dashboard() } Text("${w.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
.padding(horizontal = 8.dp, vertical = 2.dp), }
) { }
Spacer(Modifier.width(4.dp))
}
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
Column(horizontalAlignment = Alignment.End) {
Text( Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)), DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -173,17 +207,27 @@ private fun CarNavBar(navigator: CarNavigator) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
NavItem( NavItem(
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
) { navigator.notifications() } ) { navigator.notifications() }
} }
}
}
if (showNowPlaying) { /** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
NavNowPlaying(navigator, Modifier.align(Alignment.Center)) @Composable
} private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
} val scheme = MaterialTheme.colorScheme
} Box(
Modifier
.clip(RoundedCornerShape(18.dp))
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 6.dp),
contentAlignment = Alignment.Center,
) { content() }
} }
@Composable @Composable
@@ -212,19 +256,19 @@ 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. */ /** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
@Composable @Composable
private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) { private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
val media = CarServices.media val media = CarServices.media
val phase by media.phase.collectAsState() val phase by media.phase.collectAsState()
val now by media.now.collectAsState() val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.hasTrack) return if (phase != CarMedia.Phase.READY || !now.isActive) return
val context = LocalContext.current val context = LocalContext.current
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
Surface( Surface(
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(50), shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.secondaryContainer, color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer, contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
) { ) {
Row( Row(
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
@@ -233,6 +277,7 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
) { ) {
Row( Row(
Modifier Modifier
.weight(1f, fill = false)
.clip(RoundedCornerShape(50)) .clip(RoundedCornerShape(50))
.clickable { navigator.media() }, .clickable { navigator.media() },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -242,7 +287,8 @@ private fun NavNowPlaying(navigator: CarNavigator, modifier: Modifier) {
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
} }
Text( 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, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
@@ -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() }
@@ -3,6 +3,8 @@ package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -73,8 +75,12 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val phase by media.phase.collectAsStateWithLifecycle() val phase by media.phase.collectAsStateWithLifecycle()
val now by media.now.collectAsStateWithLifecycle() val now by media.now.collectAsStateWithLifecycle()
val browse by media.browse.collectAsStateWithLifecycle() val browse by media.browse.collectAsStateWithLifecycle()
val rejection by media.rejection.collectAsStateWithLifecycle()
val capture by CarAudio.capture.collectAsStateWithLifecycle() val capture by CarAudio.capture.collectAsStateWithLifecycle()
var tab by remember { mutableStateOf(Tab.BROWSE) } val navigator = LocalCarNavigator.current
var tab by remember {
mutableStateOf(if (navigator.lyricsRequested) Tab.LYRICS else Tab.BROWSE).also { navigator.lyricsRequested = false }
}
// The connection outlives this screen: the home screen's player is the same one. // The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) } LaunchedEffect(app) { CarServices.openMedia(app) }
@@ -88,10 +94,25 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
} }
when (phase) { when (phase) {
CarMedia.Phase.REJECTED -> Notice( 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.", "${app.label} would not let Gearslip browse it.",
"Some media apps only accept Google's own host.", "Some media apps only accept Google's own host.",
) )
}
CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null) CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null)
CarMedia.Phase.READY -> Row( CarMedia.Phase.READY -> Row(
Modifier.fillMaxSize().padding(top = 6.dp), Modifier.fillMaxSize().padding(top = 6.dp),
@@ -108,7 +129,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
} }
} }
when (tab) { when (tab) {
Tab.BROWSE -> BrowsePanel(browse, media, Modifier.fillMaxSize()) Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
} }
} }
@@ -189,10 +210,23 @@ private fun AudioBadge(capture: CarAudio.Capture) {
} }
@Composable @Composable
private fun Notice(title: String, detail: String?) { private fun Notice(title: String, detail: String?, action: Pair<String, () -> Unit>? = null) {
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { Column(
Text(title, style = MaterialTheme.typography.titleMedium) Modifier.fillMaxSize().padding(horizontal = 24.dp),
detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } 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) }
}
} }
} }
@@ -271,7 +305,7 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
// --- browsing -------------------------------------------------------------------------------- // --- browsing --------------------------------------------------------------------------------
@Composable @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) { Column(modifier) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
if (media.canGoUp) { if (media.canGoUp) {
@@ -285,6 +319,10 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
} }
when { when {
browse.loading -> Notice("Loading…", null) 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.failed -> Notice("Could not load this list.", null)
browse.entries.isEmpty() -> Notice("Nothing here.", null) browse.entries.isEmpty() -> Notice("Nothing here.", null)
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -48,8 +48,8 @@ import kotlinx.coroutines.withContext
@Composable @Composable
fun PhoneAppsScreen() { fun PhoneAppsScreen() {
val context = LocalContext.current val context = LocalContext.current
val apps by produceState(initialValue = emptyList<PhoneApp>(), context) { val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
value = withContext(Dispatchers.IO) { PhoneApps.installed(context) } value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
} }
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
@@ -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}") }
}
}
@@ -10,12 +10,15 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -82,16 +85,17 @@ fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (t
@Composable @Composable
private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) { private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
val pan by CarServices.nav.panMode.collectAsState()
Box(modifier.fillMaxWidth()) { 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)) RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp))
} }
RightEdgeControls( RightEdgeControls(
template.actionStrip, if (pan) null else template.actionStrip,
template.mapActionStrip, template.mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp), Modifier.align(Alignment.TopEnd).padding(12.dp),
) )
template.destinationTravelEstimate?.let { if (!pan) template.destinationTravelEstimate?.let {
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart)) TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
} }
} }
@@ -207,25 +211,39 @@ private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Mod
// --- map with a side pane ---------------------------------------------------------------------- // --- map with a side pane ----------------------------------------------------------------------
/** /**
* The shape every remaining map-backed template shares: a pane down the left, the app's map * The shape every remaining map-backed template shares: the app's map across the whole surface,
* showing through on the right, and both action strips stacked at the right edge. * 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 @Composable
private fun MapPaneLayout( private fun MapPaneLayout(
modifier: Modifier, modifier: Modifier,
actionStrip: androidx.car.app.model.ActionStrip?, actionStrip: androidx.car.app.model.ActionStrip?,
mapActionStrip: androidx.car.app.model.ActionStrip?, mapActionStrip: androidx.car.app.model.ActionStrip?,
pane: @Composable (Modifier) -> Unit, fullHeight: Boolean = false,
pane: @Composable () -> Unit,
) { ) {
Box(modifier.fillMaxWidth()) { // Pan mode hands the whole screen to the map, leaving only its own controls.
pane( 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 Modifier
.align(Alignment.CenterStart) .align(Alignment.TopStart)
.fillMaxHeight() .padding(12.dp)
.fillMaxWidth(CONTENT_PANE_FRACTION), .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( RightEdgeControls(
actionStrip, if (pan) null else actionStrip,
mapActionStrip, mapActionStrip,
Modifier.align(Alignment.TopEnd).padding(12.dp), Modifier.align(Alignment.TopEnd).padding(12.dp),
) )
@@ -238,8 +256,9 @@ private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Mod
modifier, modifier,
template.actionStrip, template.actionStrip,
template.mapController?.mapActionStrip, template.mapController?.mapActionStrip,
) { paneModifier -> fullHeight = template.contentTemplate.needsFullHeight(),
ContentTemplate(template.contentTemplate, paneModifier) ) {
ContentTemplate(template.contentTemplate, Modifier)
} }
} }
@@ -249,8 +268,8 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
modifier, modifier,
template.actionStrip, template.actionStrip,
template.mapController?.mapActionStrip, 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 { Column {
HeaderBar( HeaderBar(
headerTitle(template.header, ""), headerTitle(template.header, ""),
@@ -266,26 +285,26 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
@Composable @Composable
private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) { private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier -> MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
ListPane( ListPane(
headerTitle(template.header, template.title.text()), headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction, template.header?.startHeaderAction ?: template.headerAction,
template.isLoading, template.isLoading,
template.itemList, template.itemList,
paneModifier, Modifier,
) )
} }
} }
@Composable @Composable
private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) { private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, null) { paneModifier -> MapPaneLayout(modifier, template.actionStrip, null) {
ListPane( ListPane(
template.title.text(), template.title.text(),
template.headerAction, template.headerAction,
template.isLoading, template.isLoading,
template.itemList, template.itemList,
paneModifier, Modifier,
) )
} }
} }
@@ -293,15 +312,15 @@ private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifie
/** Route choices down the side, with the app's "start" action pinned under them. */ /** Route choices down the side, with the app's "start" action pinned under them. */
@Composable @Composable
private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifier: Modifier) { private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifier: Modifier) {
MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier -> MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) {
ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) { ChromeSurface(Modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
Column(Modifier.fillMaxHeight()) { Column {
HeaderBar( HeaderBar(
headerTitle(template.header, template.title.text()), headerTitle(template.header, template.title.text()),
template.header?.startHeaderAction ?: template.headerAction, template.header?.startHeaderAction ?: template.headerAction,
template.header?.endHeaderActions.orEmpty(), template.header?.endHeaderActions.orEmpty(),
) )
Box(Modifier.weight(1f)) { Box(Modifier.weight(1f, fill = false)) {
if (template.isLoading) { if (template.isLoading) {
Text("Finding routes…", Modifier.padding(14.dp)) Text("Finding routes…", Modifier.padding(14.dp))
} else { } else {
@@ -338,3 +357,8 @@ private const val CONTENT_PANE_FRACTION = 0.42f
/** Fraction of the surface the travel-estimate bar covers along the bottom. */ /** Fraction of the surface the travel-estimate bar covers along the bottom. */
private const val ESTIMATE_FRACTION = 0.14f 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
@@ -103,7 +103,10 @@ private fun ListContent(template: ListTemplate, modifier: Modifier) {
template.actionStrip, template.actionStrip,
) )
if (template.isLoading) Loading() 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)) ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
} }
} }
@@ -117,7 +120,7 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
template.header?.endHeaderActions.orEmpty(), template.header?.endHeaderActions.orEmpty(),
template.actionStrip, 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)) ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp))
} }
} }
@@ -484,7 +487,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
@Composable @Composable
private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) { private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) {
ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) { ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) {
Column(Modifier.fillMaxSize(), content = content) Column(Modifier.fillMaxWidth(), content = content)
} }
} }
@@ -30,6 +30,21 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -74,9 +89,11 @@ internal fun ChromeSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp), shape: Shape = RoundedCornerShape(16.dp),
alpha: Float = 1f, alpha: Float = 1f,
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
Surface( Surface(
shadowElevation = shadowElevation,
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha), color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
contentColor = MaterialTheme.colorScheme.onSurface, contentColor = MaterialTheme.colorScheme.onSurface,
shape = shape, shape = shape,
@@ -109,7 +126,12 @@ internal fun HeaderBar(
endActions: List<Action> = emptyList(), endActions: List<Action> = emptyList(),
actionStrip: ActionStrip? = null, 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 if (title.isEmpty() && startAction == null && end.isEmpty()) return
Row( Row(
@@ -120,8 +142,9 @@ internal fun HeaderBar(
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) } startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
Text( Text(
title, title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -130,6 +153,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. */ /** Reads a header whichever way the template carries it. */
internal fun headerTitle(header: Header?, fallback: String): String = internal fun headerTitle(header: Header?, fallback: String): String =
header?.title.text().ifEmpty { fallback } header?.title.text().ifEmpty { fallback }
@@ -150,8 +189,35 @@ internal fun RightEdgeControls(
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() } val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() } val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() }
if (actions.isEmpty() && mapActions.isEmpty()) return 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()) { if (actions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
actions.forEach { ActionButton(it) } actions.forEach { ActionButton(it) }
@@ -161,43 +227,104 @@ internal fun RightEdgeControls(
} }
} }
/** [large] is the pane's main call to action: it shares the card's full width, taller, in bigger type. */
@Composable @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() } val drawable = actions.filter { it.isDrawable() }
if (drawable.isEmpty()) return if (drawable.isEmpty()) return
Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(if (large) modifier.fillMaxWidth() else modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
drawable.forEach { ActionButton(it) } 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 @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 dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text() 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 val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM
Surface( Surface(
color = background, color = background,
contentColor = Color.White, contentColor = content,
shape = RoundedCornerShape(24.dp), shape = if (title.isEmpty()) androidx.compose.foundation.shape.CircleShape else RoundedCornerShape(if (large) 28.dp else 24.dp),
modifier = Modifier.clickable(enabled = action.isEnabled) { modifier = modifier.clickable(enabled = action.isEnabled) {
action.onClickDelegate.click("action") 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")
}, },
) { ) {
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 = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
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( Row(
Modifier.padding(horizontal = if (title.isEmpty()) 10.dp else 14.dp, vertical = 10.dp), Modifier.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action)) if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
if (title.isNotEmpty()) {
if (hasGlyph) Spacer(Modifier.width(8.dp)) if (hasGlyph) Spacer(Modifier.width(8.dp))
Text(title, style = MaterialTheme.typography.labelLarge, maxLines = 1) Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
} }
} }
} }
} }
/** 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. */ /** 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) { private fun standardFor(action: Action): Int? = when (action.type) {
Action.TYPE_BACK -> CarIcon.TYPE_BACK Action.TYPE_BACK -> CarIcon.TYPE_BACK
@@ -270,8 +397,13 @@ internal fun ItemListColumn(
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp)) if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
return 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)) { 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 +413,9 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
val items = list?.items?.filterIsInstance<GridItem>().orEmpty() val items = list?.items?.filterIsInstance<GridItem>().orEmpty()
if (items.isEmpty()) return if (items.isEmpty()) return
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(96.dp), columns = GridCells.Adaptive(84.dp),
modifier = modifier, modifier = modifier,
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
@@ -319,26 +451,47 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
@Composable @Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) { internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
val rows = pane?.rows.orEmpty() // 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() }
Column(modifier) { Column(modifier) {
rows.forEach { RowItem(it) } rows.forEach { RowItem(it, large = true) }
ActionRow( ActionRow(
pane?.actions.orEmpty(), pane?.actions.orEmpty(),
Modifier.padding(horizontal = 14.dp, vertical = 8.dp), Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
large = true,
) )
} }
} }
@Composable @Composable
internal fun RowItem(row: CarRow) { internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val clickable = row.onClickDelegate != null && row.isEnabled 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( Row(
Modifier Modifier
.fillMaxWidth() .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), .padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (selection != null) {
RadioButton(selected = selection.selected, onClick = null)
Spacer(Modifier.width(12.dp))
}
row.image?.let { row.image?.let {
CarGlyph(it, Modifier.size(28.dp)) CarGlyph(it, Modifier.size(28.dp))
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
@@ -346,24 +499,32 @@ internal fun RowItem(row: CarRow) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
row.title.text(), row.title.text(),
style = MaterialTheme.typography.bodyLarge, style = if (large) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyLarge,
maxLines = 1, fontWeight = if (large) FontWeight.SemiBold else null,
maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
row.texts.orEmpty().take(2).forEach { line -> row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
Text( Text(
line.text(), line.text(),
style = MaterialTheme.typography.bodySmall, style = if (large) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = if (large) 2 else 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
} }
ActionRow(row.actions.orEmpty()) 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 = internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
@@ -381,3 +542,6 @@ private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean {
} }
return seen > 0 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,
)
}
@@ -88,6 +88,9 @@ class CarAppConnection(private val context: Context) {
private var binding: ServiceConnection? = null private var binding: ServiceConnection? = null
private var component: ComponentName? = 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 frameWidth = 800
private var frameHeight = 480 private var frameHeight = 480
private var frameDensity = 160 private var frameDensity = 160
@@ -120,20 +123,59 @@ class CarAppConnection(private val context: Context) {
binding = connection binding = connection
LocationKeepAlive.start(context) LocationKeepAlive.start(context)
val bind = {
var bindError: Throwable? = null
val bound = runCatching { val bound = runCatching {
context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES) context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES)
}.getOrElse { }.getOrElse {
GearslipLog.e("bindService threw for ${app.component}", it) GearslipLog.e("bindService threw for ${app.component}", it)
bindError = it
false false
} }
if (!bound) { if (!bound) {
binding = null binding = null
fail(Phase.FAILED, "bindService refused") fail(Phase.REJECTED, explainBindFailure(app.component, bindError))
} else { } else {
GearslipLog.i("host: binding to ${app.component.flattenToShortString()}") GearslipLog.i("host: binding to ${app.component.flattenToShortString()}")
} }
} }
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 {
"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() { fun disconnect() {
lent?.let { sendSurfaceDestroyed(it) } lent?.let { sendSurfaceDestroyed(it) }
lent = null lent = null
@@ -147,6 +189,7 @@ class CarAppConnection(private val context: Context) {
binding = null binding = null
this.carApp = null this.carApp = null
appManager = null appManager = null
_panMode.value = false
surfaceCallback = null surfaceCallback = null
component = null component = null
_template.value = null _template.value = null
@@ -244,6 +287,32 @@ class CarAppConnection(private val context: Context) {
}) { manager.getTemplate(it) } }) { 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 ------------------------------------------- // --- the surface the app draws its map into -------------------------------------------
/** /**
@@ -498,7 +567,7 @@ class CarAppConnection(private val context: Context) {
override fun onFailure(response: Bundleable?) { override fun onFailure(response: Bundleable?) {
val value = response?.let { runCatching { it.get() }.getOrNull() } 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) } runCatching { send(callback) }
@@ -140,6 +140,27 @@ fun InputCallbackDelegate?.inputSubmitted(text: String) {
.onFailure { GearslipLog.w("host: could not submit input: ${it.message}") } .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. */ /** The driver asked for fresher content, e.g. by pulling a place list. */
fun OnContentRefreshDelegate?.refresh() { fun OnContentRefreshDelegate?.refresh() {
val delegate = this ?: return val delegate = this ?: return
@@ -0,0 +1,20 @@
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
)
fun works(packageName: String): Boolean = packageName in working
}
@@ -1,15 +1,20 @@
package app.seb3thehacker.gearslip.media package app.seb3thehacker.gearslip.media
import android.content.ComponentName
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.media.MediaMetadata import android.media.MediaMetadata
import android.media.browse.MediaBrowser import android.media.browse.MediaBrowser
import android.media.session.MediaController import android.media.session.MediaController
import android.media.session.MediaSessionManager
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock import android.os.SystemClock
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -47,6 +52,15 @@ class NowPlaying(
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
/**
* 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 fun canDo(action: Long) = actions and action != 0L
/** The position now, advanced from the last report the way the platform's own UIs do. */ /** The position now, advanced from the last report the way the platform's own UIs do. */
@@ -64,6 +78,8 @@ class BrowseState(
val entries: List<MediaEntry> = emptyList(), val entries: List<MediaEntry> = emptyList(),
val loading: Boolean = true, val loading: Boolean = true,
val failed: Boolean = false, 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 +91,15 @@ class CarMedia(private val context: Context) {
enum class Phase { IDLE, CONNECTING, READY, REJECTED } enum class Phase { IDLE, CONNECTING, READY, REJECTED }
/** Why a connection was [Phase.REJECTED], so the screen can say something useful. */
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) private val _phase = MutableStateFlow(Phase.IDLE)
val phase: StateFlow<Phase> = _phase.asStateFlow() val phase: StateFlow<Phase> = _phase.asStateFlow()
@@ -92,13 +117,14 @@ class CarMedia(private val context: Context) {
fun connect(app: MediaApp) { fun connect(app: MediaApp) {
disconnect() disconnect()
connecting = app
_phase.value = Phase.CONNECTING _phase.value = Phase.CONNECTING
_browse.value = BrowseState() _browse.value = BrowseState()
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() { val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
override fun onConnected() = this@CarMedia.onConnected(app) override fun onConnected() = this@CarMedia.onConnected(app)
override fun onConnectionFailed() { override fun onConnectionFailed() {
GearslipLog.w("media: ${app.label} refused the connection") GearslipLog.w("media: ${app.label} refused the connection to its library")
_phase.value = Phase.REJECTED attachToSession(app)
} }
override fun onConnectionSuspended() { override fun onConnectionSuspended() {
GearslipLog.w("media: ${app.label} went away") GearslipLog.w("media: ${app.label} went away")
@@ -112,6 +138,44 @@ 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)
}
_browse.value = BrowseState(loading = false, unavailable = true)
_phase.value = Phase.READY
}
private fun onConnected(app: MediaApp) { private fun onConnected(app: MediaApp) {
val mb = browser ?: return val mb = browser ?: return
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}") GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
@@ -165,6 +229,8 @@ class CarMedia(private val context: Context) {
trail = path.map { it.second }, trail = path.map { it.second },
entries = children.map(::entryOf), entries = children.map(::entryOf),
loading = false, 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) { override fun onError(parentId: String) {
@@ -222,6 +288,8 @@ class CarMedia(private val context: Context) {
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras) fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
fun disconnect() { fun disconnect() {
connecting = null
main.removeCallbacksAndMessages(null)
runCatching { controller?.unregisterCallback(callback) } runCatching { controller?.unregisterCallback(callback) }
runCatching { subscribed?.let { browser?.unsubscribe(it) } } runCatching { subscribed?.let { browser?.unsubscribe(it) } }
runCatching { browser?.disconnect() } runCatching { browser?.disconnect() }
@@ -231,4 +299,9 @@ class CarMedia(private val context: Context) {
_phase.value = Phase.IDLE _phase.value = Phase.IDLE
_now.value = NowPlaying() _now.value = NowPlaying()
} }
private companion object {
const val SESSION_RETRIES = 6
const val SESSION_RETRY_MS = 500L
}
} }
@@ -24,6 +24,14 @@ object PhoneApps {
private const val ICON_PX = 128 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> { fun installed(context: Context): List<PhoneApp> {
val manager = context.packageManager val manager = context.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
@@ -50,6 +50,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.ScreenProjector import app.seb3thehacker.gearslip.ScreenProjector
import app.seb3thehacker.gearslip.TouchPoint
import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarUi import app.seb3thehacker.gearslip.car.CarUi
@@ -122,8 +123,14 @@ fun CarPreviewScreen(onBack: () -> Unit) {
if (viewWidth > 0 && viewHeight > 0) { if (viewWidth > 0 && viewHeight > 0) {
projector.dispatchTouch( projector.dispatchTouch(
event.actionMasked.toCarAction(), event.actionMasked.toCarAction(),
event.x * width / viewWidth, event.actionIndex,
event.y * height / viewHeight, List(event.pointerCount) { i ->
TouchPoint(
event.getPointerId(i),
event.getX(i) * width / viewWidth,
event.getY(i) * height / viewHeight,
)
},
) )
} }
true true
@@ -230,10 +237,11 @@ private tailrec fun Context.activity(): Activity? = when (this) {
else -> null 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) { private fun Int.toCarAction(): Int = when (this) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE,
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL,
MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_POINTER_UP,
-> this -> this
else -> MotionEvent.ACTION_CANCEL else -> MotionEvent.ACTION_CANCEL
} }
@@ -49,7 +49,7 @@ private fun CompatWarning() {
Text( Text(
"Gearslip may not connect on cars or head units newer than about 2020. " + "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 " + "Older systems are more likely to work; newer firmware often rejects the " +
"connection outright. See SPIKE_FINDINGS.md in the project for details.", "connection outright.",
) )
}, },
confirmButton = { confirmButton = {
@@ -1,6 +1,9 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -18,16 +21,21 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.SessionStatus import app.seb3thehacker.gearslip.SessionStatus
@Composable @Composable
@@ -38,6 +46,8 @@ fun HomeScreen(
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
) { ) {
val status by SessionStatus.state.collectAsStateWithLifecycle() val status by SessionStatus.state.collectAsStateWithLifecycle()
val context = LocalContext.current
var debug by remember { mutableStateOf(AppSettings.debugMode(context)) }
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
Column( Column(
@@ -65,6 +75,28 @@ fun HomeScreen(
) )
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
StatusCard(status) StatusCard(status)
Spacer(Modifier.height(24.dp))
SetupSteps()
Spacer(Modifier.height(24.dp))
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text("Debug mode", style = MaterialTheme.typography.titleMedium)
Text(
if (debug) "Full protocol logging, live logs and car preview."
else "Quiet logging. Warnings and errors are still recorded.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = debug, onCheckedChange = {
debug = it
AppSettings.setDebugMode(context, it)
})
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
} }
@@ -80,7 +112,7 @@ fun HomeScreen(
Text("Disconnect") Text("Disconnect")
} }
} }
if (!live) { if (!live && debug) {
FilledTonalButton( FilledTonalButton(
onClick = onOpenCarPreview, onClick = onOpenCarPreview,
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
@@ -89,9 +121,11 @@ fun HomeScreen(
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (debug) {
FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) { FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Live logs") Text("Live logs")
} }
}
OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) { OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) {
Text("Settings") Text("Settings")
} }
@@ -144,3 +178,61 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
private fun Context.appVersionName(): String = private fun Context.appVersionName(): String =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?" runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
/** One step of the setup list: a sentence that says what to do, and optionally how. */
private class Step(val action: String, val detail: String? = null)
private val SETUP_STEPS = 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 the certificate.", "Open Settings and choose Import certificate."),
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
)
/**
* What to do before the first drive, in order. Kept in a narrow card with room at the sides so
* the lines stay short enough to read at a glance.
*/
@Composable
private fun SetupSteps() {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.widthIn(max = 560.dp).padding(horizontal = 16.dp),
) {
Column(Modifier.padding(horizontal = 24.dp, vertical = 22.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
Text(
"How to connect",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
)
SETUP_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,
)
}
}
}
}
}
}
}
}
@@ -1,6 +1,14 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import android.content.Intent 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.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -17,7 +25,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -78,20 +85,34 @@ fun LogsScreen(onBack: () -> Unit) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
} }
}, },
actions = { )
TextButton(onClick = { },
bottomBar = {
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
Row(
Modifier.fillMaxWidth().navigationBarsPadding().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Button(
onClick = {
scope.launch { scope.launch {
val text = withContext(Dispatchers.IO) { GearslipLog.fullText() } val uri = withContext(Dispatchers.IO) { GearslipLog.shareableFile(context) }
val send = Intent(Intent.ACTION_SEND) val send = Intent(Intent.ACTION_SEND)
.setType("text/plain") .setType("text/plain")
.putExtra(Intent.EXTRA_SUBJECT, "Gearslip log") .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")) 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 = { floatingActionButton = {
if (!follow && lines.isNotEmpty()) { if (!follow && lines.isNotEmpty()) {
@@ -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>
+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=