diff --git a/.gitignore b/.gitignore
index 75731ce..577fd18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,7 +12,14 @@ local.properties
# The head unit certificate. Never committed or shared beyond who Seb sends it to directly.
/cert
+# The release signing key and its passwords. Losing these breaks app updates; leaking them
+# lets anyone sign an APK as Gearslip.
+*.jks
+*.keystore
+keystore.properties
+
# Local investigation script; not meant for the public repo.
/docs/cert-sweep.sh
archive/
-CLAUDE.md
\ No newline at end of file
+CLAUDE.md
+*.pdf
\ No newline at end of file
diff --git a/README.md b/README.md
index 4e30d4b..64bdd1d 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ Real progress, with one hard limit that's worth knowing before you build on this
doesn't ship one.** A head unit only accepts a phone identity that chains to Google's
Automotive Link certificate authority - something only Google issues. Without one, Gearslip
still runs and renders everything correctly against a debug harness, but a real car will
- reject the connection outright. See [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) for what was
+ reject the connection outright. See [docs/SPIKE_FINDINGS.md](docs/SPIKE_FINDINGS.md) for what was
tested, including one publicly-known leaked certificate that works on some older head
units and is rejected by newer firmware and by Google's own reference tooling.
- **No adb, root, or Shizuku is required to run it.** Every feature is gated on an ordinary
@@ -45,9 +45,11 @@ The APK lands at `build/Gearslip-debug.apk`.
## Further reading
-- [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) - the certificate investigation in full: what was
+- [docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) - how to write apps Gearslip can render:
+ templated apps, media apps, and the Gearslip app protocol.
+- [docs/SPIKE_FINDINGS.md](docs/SPIKE_FINDINGS.md) - the certificate investigation in full: what was
tried, what a real head unit does and doesn't accept, and why.
-- [CUSTOM_HEADUNIT_SCOPING.md](CUSTOM_HEADUNIT_SCOPING.md) - the original scoping of what a
+- [docs/CUSTOM_HEADUNIT_SCOPING.md](docs/CUSTOM_HEADUNIT_SCOPING.md) - the original scoping of what a
custom head unit client would need.
## License
diff --git a/docs/BUILDING_APPS.md b/docs/BUILDING_APPS.md
new file mode 100644
index 0000000..593f77a
--- /dev/null
+++ b/docs/BUILDING_APPS.md
@@ -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
+
+
+ - 4cc56ebdd13a54d260eca9b7e2b2ae6b30e2f225bfda8f508f0709edf084948b,app.seb3thehacker.gearslip
+
+```
+
+```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 -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
+
+
+
+
+
+
+```
+
+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
+
+
+
+```
+
+```xml
+
+
+
+
+```
+
+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
+
+
+
+
+
+
+```
+
+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.
diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts
index dcce321..a75a959 100644
--- a/gearslip/build.gradle.kts
+++ b/gearslip/build.gradle.kts
@@ -1,3 +1,4 @@
+import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile
@@ -13,6 +14,16 @@ plugins {
alias(libs.plugins.kotlin.compose)
}
+// The release signing key, read from an untracked keystore.properties beside this file or at the
+// repository root. Third-party apps allowlist Gearslip by the SHA-256 of this certificate, so the
+// key must stay the same for the life of the project - and must never be committed.
+val keystoreProperties = Properties().apply {
+ listOf(
+ rootProject.file("keystore.properties"),
+ project.file("keystore.properties"),
+ ).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
+}
+
android {
namespace = "app.seb3thehacker.gearslip"
compileSdk = 37
@@ -25,6 +36,25 @@ android {
versionName = "0.0.02-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: /build/gearslip/... instead of
// /gearslip/build/..., so everything this project produces lands under one
// root-level build/ directory.
diff --git a/keystore.properties.example b/keystore.properties.example
new file mode 100644
index 0000000..720bdbd
--- /dev/null
+++ b/keystore.properties.example
@@ -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=