A custom companion HOME launcher for the Choiceway GT6-EAU head unit
(Android 13 / API 33, 1920x720 landscape @240dpi, rooted). It integrates with the
vendor gateway com.szchoiceway.eventcenter per ../CAR_API.md.
Companion, not a replacement. This app registers as an alternative HOME (
MAIN + HOME + DEFAULT + LAUNCHER). The user chooses whether to make it the default home from the system chooser; the stock launcher (com.szchoiceway.customerui) stays installed. Privileged car actions (writing SysVar, reverse/SWC/day-night broadcasts) go through root — the privileged/system-app route is ruled out, since the vendor platform key is confirmed unobtainable. See Root-native tier (v2.9) below.
Emulator shots are from the emulator farm at the unit's geometry; the two marked "on the real unit" were pulled from the head unit over the private network on 2026-09-08. None is mocked.
launcher/
├── settings.gradle.kts # includes :app and :carlib; google/mavenCentral/JitPack repos
├── build.gradle.kts # AGP 8.5.2, Kotlin 2.0.20 (+ compose compiler plugin), apply false
├── gradle.properties # AndroidX, non-transitive R, parallel/caching
├── gradle/wrapper/… # Gradle 8.9 wrapper
│
├── carlib/ # Android library — the car integration layer
│ ├── build.gradle.kts # namespace com.ripostelabs.carlauncher.carlib, aidl=true, libsu dep
│ ├── consumer-rules.pro # keep vendor AIDL/Parcelable names
│ └── src/main/
│ ├── AndroidManifest.xml # <uses-permission com.szchoiceway.permission.broadcast>
│ ├── aidl/com/szchoiceway/eventcenter/
│ │ ├── ICommunication.aidl # gateway → app callback (notifyMessage/checkIsActive)
│ │ ├── ICallbackfn.aidl # radio/EQ/CAN setter callback (notifyEvt / checkIsActive)
│ │ └── IEventService.aidl # bound control service (SUBSET; ordinals = TODO)
│ └── java/com/ripostelabs/carlauncher/carlib/
│ ├── CarEvents.kt # BroadcastReceiver → Flows/callbacks (reverse, ACC, SWC, day/night)
│ ├── SysVar.kt # ContentResolver read + root `content` write of SysVarProvider
│ ├── RootShell.kt # `su -c` via libsu (reflective) or ProcessBuilder fallback
│ └── CarService.kt # binds vendor EventService via IEventService AIDL
│
└── app/ # Android application — the launcher UI
├── build.gradle.kts # applicationId com.ripostelabs.carlauncher, compose, targetSdk 33
├── proguard-rules.pro
└── src/main/
├── AndroidManifest.xml # HOME activity, singleTask, landscape, QUERY_ALL_PACKAGES
├── res/… # dark car theme, strings, adaptive launcher icon
└── java/com/ripostelabs/carlauncher/
├── MainActivity.kt # ComponentActivity + setContent; wires CarEvents/CarService
├── AppRepository.kt # queryIntentActivities(MAIN/LAUNCHER) + launch
└── ui/
├── theme/ # Color.kt / Type.kt / Theme.kt (Material3 dark)
├── HomeScreen.kt # app drawer + status bar + widgets + reverse overlay
├── StatusBar.kt # clock + ACC/day-night chips
├── AppDrawer.kt # LazyVerticalGrid of launchable apps, big tap targets
├── MediaCard.kt # now-playing card (MediaSession, or the CarPlay row)
├── ReverseOverlay.kt# full-screen black overlay on reverse (camera TODO)
└── ComposeUtil.kt # lifecycle-aware Flow → State helper
Requires JDK 17 and an Android SDK with platform 34 and build-tools. Point
JAVA_HOME and ANDROID_HOME at them (Android Studio's bundled copies work), then:
cd launcher
./gradlew :app:assembleDebugThe Gradle wrapper pins Gradle 8.9, so no system Gradle is needed. The version is derived from git history, so build from a full clone — a shallow clone fails the build with an explicit message rather than producing a wrong versionCode.
Output APK: app/build/outputs/apk/debug/app-debug.apk.
carlibpullscom.github.topjohnwu.libsu:corefrom JitPack. If the network blocks JitPack,RootShellstill works via its pure-ProcessBuildersu -cfallback — you can drop the libsu line fromcarlib/build.gradle.ktsin that case.
adb install -r app/build/outputs/apk/debug/app-debug.apk
# then press HOME and pick "Car Launcher" (set as default if you want it to be the home)There is no privileged/system-app install to graduate to. It would need the vendor's platform
key (sharedUserId=android.uid.system must match the running framework's signature), and that key
is confirmed unobtainable — ../CUSTOM_ANDROID.md §2b, ../LAUNCHER_DESIGN.md §6.4. The
signature-level vendor broadcasts and the SysVar writes come from root instead; see
Root-native tier (v2.9).
Every green build of main is tagged v<versionName>+<versionCode>.g<short-sha> (e.g.
v0.7+146.g91dd836) and the release APK from that same CI run is published as a
GitHub Release against the tag, with the APK's SHA-256 in the release body.
Both version fields are derived from git at build time (app/build.gradle.kts):
versionCode is the commit count at the merge-base with origin/main, so every merge
to main raises it by exactly one, and versionName is the bare base (0.7). Nobody
bumps a version by hand, and no PR touches a version line. Because every build on a
line shares its versionName, a build is always named with the code beside it —
0.7 (146) in the app, +146 in the tag — and the commit SHA stays in the tag as the
belt-and-braces identity. Historical note: before versionCode 71 the fields
were hand-claimed per PR; they repeat in that range (two merges both shipped 61), so
only the SHA separates those builds.
Side-load only that asset. To identify what is already installed, pull the APK off the
head unit and match its sha256sum against the release body.
| Capability | As normal app | Notes |
|---|---|---|
| Be HOME, app drawer, launch apps | ✅ | standard |
| Unprotected events (ACC, media, radio) | ✅ | |
Protected events (reverse ACTION_BACKCAR_*, SWC STEER_WHEEL_INFOR, day/night) |
com.szchoiceway.permission.broadcast |
likely signature, and the platform key is unobtainable → captured at uid 0 by the root tier below; without root CarEvents falls back to unprotected MCU_MSG_BACKCAR_* |
| Read SysVar provider | ✅ | SysVar.getString/readAll |
| Write SysVar provider | ❌ direct | SysVar.putString routes through root (content shell) |
Bind EventService, read-only AIDL |
✅ | exported service |
| AIDL control side-effects | not recovered by root either; see Root-native tier | |
| Read the raw vehicle CAN bus | ✅ no root | over the USB host API to a CANable; there is no CDC-ACM driver on this kernel, so no serial node — see Raw vehicle bus below |
| Switch lights and servos on an accessory board | ✅ no root | plain HTTP to a board on the car's private network; cleartext is enabled for it in the manifest — see Accessories below |
| Launch emulator frontends | ✅ | behind the parked-only gate; RetroArch aarch64 is the build for this unit — see Games below |
A second, independent source of vehicle data beside the vendor MCU digest: the real Toyota body bus, read 1:1 through a CANable 2.0 plugged into the head unit's USB port. Measured in the car at ~1214 frames/s across 111 ids.
CANable ──USB bulk──▶ CanableUsbLink ──▶ SlcanCodec ──▶ CanableSource ──┬──▶ CanableRecorder (candump)
└──▶ VehicleState ──▶ Vehicle screen
MCU broadcast ──▶ HiworldCanDecoder ────────────────────────────────────────▶ VehicleState
- No driver, no root. The kernel ships no CDC-ACM module, so
/dev/ttyACM*never appears on either port. The launcher claims the device withUsbManagerand does the bulk transfers itself. AUSB_DEVICE_ATTACHEDfilter grants permission on attach. - Recording is a foreground service and starts hands-free when the adapter is plugged in.
Captures land under the app's external files dir in candump log format, roll at 16 MB, and the
newest six survive.
Settings → CAN frame captureshows the live adapter state and can stop it. - Grounding. A badly grounded adapter enumerates and prints its banner but ignores every command. If it talks and never answers, check GND first.
- Where the wire, the pinout and the decoded ids are documented:
../can-integration/docs/CANABLE_INTEGRATION.md.
Lights, servos and sequences, switched from Settings → Accessories. The board is any
microcontroller on the car's private network speaking the small contract in
../can-integration/docs/ACCESSORY_BOARD.md;
can-integration/tools/virtual-board.py speaks it too, for testing with no hardware, and
can-integration/board/micropython/ is the real one for an ESP32.
Accessories screen ──▶ AccessoryRuntime ──▶ AccessoryController ──▶ HttpAccessoryTransport ──▶ board
│ ▲
VehicleSnapshot ──▶ TriggerEngine ──▶ SequenceRunner (reverse → work lights, door → welcome)
- Unknown is drawn as unknown. The page has no toggle: a switch drawn off claims the thing is off, and a board that has not answered has not said that. State changes only on a confirmed reply; a 200 with the wrong state is a refusal.
- Config is a file. Push it, then Load config from file:
adb push accessory-config.json /sdcard/Android/data/com.ripostelabs.carlauncher/files/An example with three accessories, four sequences and three triggers is on the share. - Runs without the screen. Triggers evaluate for the life of the launcher, so "reverse → work lights" works with no settings page open.
Settings → Games lists installed emulator frontends and launches them behind the parked-only
gate. Matching is exact: RetroArch ships com.retroarch, .aarch64 and .ra32 as separate,
non-interchangeable installs, and this unit is arm64-v8a. The official aarch64 build is
staged on the share and installed by an internal watcher when it is missing.
Below the frontends, Library lists the ROMs under /sdcard/RetroArch/roms/<system>/ and
starts each one in RetroArch with its core already chosen (nes → fceumm, gb/gbc → gambatte,
gba → mgba, genesis → genesis_plus_gx, snes → snes9x; download the core once inside
RetroArch). The folder is not media, so the first visit asks for "All files access". Only
homebrew that its authors publish for free redistribution is staged; the SOURCES.md next to
the ROMs records origin, licence and checksum for every file.
A full, reskinned mirror of the vendor GT6 settings — see
SETTINGS_ROADMAP.md. Every category the vendor exposes is
rebuilt from our CarTheme palette and reachable from Settings on Home:
- Launcher, Display & Illumination, Reverse camera, Parking radar, Audio & EQ, Climate,
Radio, Steering wheel, Power & sleep, System & about — curated screens with friendly
toggles / sliders / pickers over the vendor SysVar store and the
IEventServiceAIDL. - All settings (advanced) — a raw browser over the live SysVar table, so all 455 vendor keys are reachable even where we haven't catalogued a friendly control.
Backing pieces: data/CarSettingsController (live snapshot + optimistic root-fallback
writes), data/SettingKeys (curated key catalog), ui/settings/* (the reskinned kit,
SettingsHost back-stack, and SettingsHub menu). Writes need root / a privileged install
(CAR_API §2.2); the hub warns when root is absent. Enum option sets are inferred from
firmware naming (the vendor settings APK that holds the value tables isn't in the decompile),
so each guessed mapping is annotated in-code and the Advanced browser shows the true strings.
The drawer hides a Choiceway app once ours stands in for it, the way it already shadows the
retired com.reveng.* twins of the suite. data/OemApps is the single table: REMOVE
(photoreader, apkinstall, weather, xbrowser, atslcarconsole: hidden always), REPLACED (radio,
musicplayer, videoplayer, gps: hidden only while the com.ripostelabs.* rewrite is installed;
the OEM System settings app only on an explicit opt-in, since it still hosts the factory menu;
the camera viewer never, there is no replacement) and KEEP (gateway, canbus2, btsuite, zlink,
the factory tools: never hidden). An OEM app whose replacement is missing stays visible, so a
unit without the suite keeps working. Two toggles under Settings ▸ Launcher ▸ Vendor apps
un-shadow either class. Setup doctor's "Vendor apps" section lists what is hidden, what is still
visible and why, the exact pm uninstall -k --user 0 <pkg> line per REMOVE package (copy it;
nothing is uninstalled from the launcher) and a warning for any KEEP package missing or
disabled; its "Gateway state" rows show getValidMode() by eSrcMode name
(data/SrcModeNames) and the Sys_SoundManager_Type / Sys_UINumber / Sys_Landscape /
Sys_CustomerType / Sys_CarType / Sys_Vehicle_deries SysVars, read-only. The matrix itself
and every citation behind it is OEM_SYSTEM.md §1.
CarEvents.speedKmh is real. The gateway broadcasts no numeric speed
(SHOW_CAR_SPEED_EVENT is a show/hide toggle, CAR_API §1.3), so carlib/GpsSpeedSource
reads it from the GNSS receiver — the one source a normal app can use — smooths the jitter,
and reverts to unknown after 5 s without a fix rather than reporting a stationary car.
Superseded 2026-09-09. The gate now runs on raw body-bus speed (RawCanDecoder), the
only reading verified against the car's own ECU, with GPS as the fallback. It is its own source
(SpeedSource.BUS), not a promoted MCU digest. pickSpeed prefers a bus reading under
BUS_SPEED_STALE_MS (2 s — the ids run at 15-39 Hz, so that is already ~30 missed frames) and
falls back to GPS the moment the adapter is unplugged or the car goes quiet, rather than gating
on a frozen number. The point of the change is where GPS is blind: a garage, a covered car park,
and the first seconds after power-on are exactly where a parked-only gate has to be right.
CarEvents.motion turns that into the PARKED / MOVING / UNKNOWN verdict behind the
LAUNCHER_DESIGN §1.4 rules, with an 8 km/h ↑ / 3 km/h ↓ hysteresis band so a car creeping in
traffic can't flap the gate.
Unknown fails open. Blocking on UNKNOWN would lock the driver out of their own launcher
in a garage, an underground car park, or on a unit where the location permission was never
granted — permanently and with no signal as to why. We only ever fail open with no reading;
any live fix above 3 km/h resolves to MOVING.
Parked-only, via the LocalParkedOnlyLock composition local (ui/ParkedOnly.kt):
- app search and its keyboard (open search closes itself if the car pulls away),
- the theme editor,
- the advanced SysVar browser,
- destructive confirmations (reboot, factory reset).
Settings ▸ Launcher ▸ Motion gating toggles enforcement and shows the live speed and verdict — needed to tell "gate open because parked" from "gate open because GPS never fixed", which look identical from the outside.
Grant the permission with:
adb shell pm grant com.ripostelabs.carlauncher android.permission.ACCESS_FINE_LOCATIONNothing animates on a loop, and no transition exceeds 400 ms (today's longest is the 320 ms
onboarding slide). A moving car is when an animation is most distracting and least noticed, so
new work stays inside that budget: no rememberInfiniteTransition, no basicMarquee, and no
tween above 400 ms on a surface that can be on screen while driving.
ui/CarFeedback.kt gives eyes-free confirmation on accepted input — haptics plus the car's own
beep() — wired to steering-wheel presses, keyboard keys and dialog buttons. The beep follows
the vendor's Set_TouchBeep preference instead of adding a competing one.
The Home cards were always glance surfaces in a 30 %-wide column. ui/MediaScreen.kt and
ui/RadioScreen.kt are the full screens you land on to actually operate playback and the
tuner (LAUNCHER_DESIGN §3.3 / §3.4). Reached three ways: tapping a card body, CENTER on a
focused card, or the steering wheel's MEDIA / RADIO keys.
MediaScreen — large art, 40 sp title, 96 dp transport targets, and a source picker over
every live MediaSession. Scrubbing is parked-only (v2.5 gate): dragging to a target position
is a sustained, eyes-on gesture, so while moving the same progress renders as a read-only bar.
Transport stays available while moving — skip and play/pause are single forgiving presses that
exist on the wheel anyway, and withholding them would push the driver to their phone.
RadioScreen — 48 sp frequency, an AM / FM band toggle, a tune slider across the dial
(parked-only, like media scrubbing; it snaps to the 100 kHz / 10 kHz step on release), seek,
six preset slots with save / recall / delete, and the tuner's status flags. Opening the screen
claims the tuner as the cabin's audio source the way the vendor radio app does
(CarService.claimRadio); AM / FM send the MCU's direct band keys (30 / 31) and re-poll until
the tuner reports the class. The sendRadioKey value table is in CAR_API §3.2.
PhoneScreen (RAV4-50) — ui/PhoneScreen.kt, reached by the wheel's PHONE / TALK key or the
status-bar phone icon. The head unit's phone Bluetooth is owned by the vendor app
com.szchoiceway.btsuite, which holds the serial protocol to the BT module and cannot be
replaced, so the screen drives it: state (device name, HFP state, caller, in-call timer) comes
from its HBCP_EVT_* broadcasts, and Answer / Hang up / dial go back as its own control
broadcasts (carlib/VendorBt.kt, CAR_API §1.3 / §1.4). Recent calls are read from its
CallListProvider (CAR_API §2.4); when that yields nothing the vendor call-record page is
offered instead, and Contacts / Bluetooth settings always open btsuite's own pages
(GotoPageNum). The dial pad and the call list are parked-only; Answer, Hang up and the vendor
page buttons stay available while moving. UNVERIFIED on the car: every control broadcast,
whether the provider answers a normal uid, and the speaking-time payload shape.
btsuite also pops its own floating call window (BTService.mFloatWnd, a WindowManager view,
not a component) on every call, on the module's HFP line rather than on our command, and nothing
in SysVar turns it off. While the launcher is resumed, data/CallPopupGuard knocks it down
through btsuite's binder (carlib/VendorBtService, IBTService.hideBTFloatWnd, which also
cancels its 1 s re-show poll); Settings ▸ Launcher ▸ Hide vendor call window (default on)
lets it back. Expect a brief flash before the hide. A SPEAKING_TIME tick is read as
ACTIVE_CALL because the HSHF 6 line is withheld while the audio is on the car.
Two things §3.3/§3.4 planned turned out not to exist, so they are not built:
- No NET. The vendor radio app's AM / FM / NET row had a third tab for its internet-radio
source.
getRadioBand()never reports it and noIEventServicemethod selects it, so the band toggle is AM / FM only. - Station name yes, radio text no.
getRadioPTYName(ordinal 19) is misnamed: the gateway stores the MCU's RDS PS frame inmRadioPSNameand returns it there, so the screen shows the station name. The genre isgetRadioPTYNum(index into a 32-entry PTY table). There is no RT getter.ZXW_RADIO_INFO_EVTis sent by the gateway on band/frequency change with int extrasRadioBndNum,RadioTuneNum,RadioCurFreq— nothing the getters do not already say. - Scan is a key.
getRadioAMSState/getRadioAPSStateonly report;sendRadioKey(13)starts a preset scan and 18 an auto-store. Neither is wired to a control yet.
Preset sync with the vendor's Rdo_MyFavorite0..5 is read-only for the same reason: the
encoding of those SysVar values is documented nowhere, and writing a guessed format would
corrupt the vendor radio's presets irreversibly. RadioScreen displays the raw strings, which is
exactly the capture needed to work the format out on-device — after which two-way sync is safe.
Vendor source switching (Bluetooth / USB / built-in) is likewise read-only: sendMode(int, boolean) is ordinal 1 and confirmed, but its value table is not in the decompile, so
MediaScreen names the current source via getValidModeTitleInfor() and does not offer to
change it.
Both screens poll rather than subscribe. setRadioCallback exists, but the ICallbackfn
signature was never recovered, so registering it would be a guess. Blocking AIDL reads stay off
the composition body — doing them inline once spun a main-thread IPC recomposition loop while
seeking.
The Zlink receiver (com.zjinnova.zlink) is driven by intent, never bundled. carlib/Zlink.kt
holds the contract recovered from the gateway's ZlinkManage.java. CarEvents.carplayState
folds the receiver's com.zjinnova.zlink status broadcasts (CONNECTED or MAIN_AUDIO_START
until DISCONNECT, PHONE_CALL_ON / OFF, phoneMode) and the gateway's telephone event into one
flow. While a phone is projected, the quick-launch CarPlay tile becomes a row of five
shortcuts: Siri, Maps, Music, Now playing, Home. Each is one REQ_SPEC_FUNC_CMD broadcast
with the gateway's own code (1500 / 1504 / 1506 / 1507 / 1508). The two lowest fill tiles
drop for the duration. The media card and MediaScreen source labels open CarPlay through
ZLINK_MAIN, and the status bar shows a "CarPlay wireless" / "CarPlay call" chip. While a
phone is projected and no MediaSession is visible, the media card shows a CarPlay row
(play flag from MAIN_AUDIO_START/STOP) instead of "No source connected": the gateway's
CarPlay now-playing is the protocol name alone, no track (ZlinkManage.java:587-605). Idle, the
tile launches the receiver as before. Nothing here writes SYS_LAUNCHER_APP_HIDE_KEY or
starts DaemonService; both make the gateway kill or re-gate Zlink.
UNVERIFIED (car offline): the status vocabulary is the gateway's side of the bridge, not a
capture from Zlink 5.4.62, whose DEX is packed; whether that build honours a
REQ_SPEC_FUNC_CMD from a sender other than the gateway; and the page / feature values of
ZLINK_MAIN (nothing in the estate calls it, so main / carplay are guesses).
v0.8 gave Home a roving focus ring, and MainActivity.routeNav handed directional keys to it only
while Home was on screen — every other screen ignored the wheel. Two pieces close that:
input/KeyPump.kt— one press-timing model for both input sources. The vendorSTEER_WHEEL_INFORbroadcast sends a down and an up and nothing between, so a held key moved the ring once; realKeyEvents auto-repeat at the platform's keyboard rate, which machine-guns a settings list. Both now feed the pump: directional keys fire, then repeat after 400 ms every 150 ms; CENTER and BACK defer their action to the release and fire a secondary action at 600 ms instead. A key cannot do both — a repeat has already fired by the time a long-press would.input/KeyBridge.kt— off Home, the ring is Compose's own focus system. Everyclickableis already a focus target with real geometry, so a hand-written focus model per screen would only drift out of sync with the layout above it. The one thing missing was a way in: SWC keys arrive as a broadcast, never as aKeyEvent, so they never reached the composition. The bridge synthesises the KeyEvent throughWindow.superDispatchKeyEvent(which bypasses our own dispatcher, so a synthetic key cannot loop). Wrap-around has no framework API, so it is composed from the one that does: a refused move means an edge, and walking as far as possible in the opposite direction lands on the far end.
input/FocusRing.kt draws where focus landed — Material's focus ripple is invisible at arm's
length in daylight. It is applied to the shared settings kit (SettingRow, ActionRow,
SettingsCategoryCard, SettingsIconTile, DialogTextButton), which covers the whole settings
suite, plus the Media, Radio and Themes screens.
Long-press actions: BACK is Home from any depth, including four screens into settings. CENTER on a Home grid tile toggles the favourite — the same thing a touch long-press does, so the wheel gains no gesture the screen lacks. On the media card it is play/pause.
Reverse restores focus. Reverse is an interruption, not navigation: the vendor takes the screen
and gives it back. LauncherFocus.saveForInterruption() / restoreAfterInterruption() put the ring
back where the driver left it.
Nothing about the radar decode was verified on a car. There is no head unit attached to the
machine this was built on. RadarState.fromRadarData still uses the same guessed offsets it has
used since v0.7, and this release does not claim otherwise — it ships the instrument that settles
them.
Settings ▸ Parking radar ▸ Raw frame capture (ui/settings/RadarCaptureScreen.kt) shows the
CAR_CAN_DATA payload byte by byte in hex, with per-offset min/max/change-count accumulated
since the last reset, next to what our decode currently makes of the same frame. CarEvents.radarRaw
publishes the payload before interpretation, including frames our guess rejects — the guess is
what the screen exists to disprove.
The capture to perform:
- Park. Engage reverse so the MCU starts broadcasting, and open the capture screen.
- Press Reset baseline with nothing near the car.
- Walk an obstacle slowly toward one corner. Watch which offset sweeps: a distance byte climbs
or falls smoothly, a status flag jumps once and stops. The eye cannot follow eight hex values at
10 Hz, which is what
min/max/changesare for — a byte that never moved stays dimmed. - Repeat per corner. The offsets that moved, in the order they moved, are the layout.
- If the number falls as the obstacle nears, the MCU sends distance rather than a bar count and
RadarState.proximityneeds inverting. - Write table to logcat dumps the result under tag
RadarCaptureforadb logcat -s RadarCapture.
Then set Layout confirmed on this car. Until that is set, the new low-speed maneuvering
side-strips (ui/RadarSideStrip.kt) stay hidden: an arc reading "clear" off a guessed offset is a
safety claim the code has not earned. The existing bars in Settings are not gated — they report
what arrived, not what it means — and every screen that shows a decode says UNCONFIRMED in words.
The strips themselves are two 44 dp rails hard against the screen edges, green → amber → red, three
arcs per corner, and completely static (motion budget). They are hidden whenever reverse is
engaged: ReverseOverlay's v0.9 rule is that the vendor composites its own reverse window above
all apps and we never contend for those pixels. The strips cover the case the vendor window does
not — creeping forward in a car park, easing out of a garage — at or below 15 km/h. An unknown speed
passes the gate on purpose, since that underground car park is where GPS has no fix.
Settings ▸ Launcher ▸ Reachability mirror swaps the glance column (media + climate) and the thumb column (quick launch + radio) so the interactive set stays under the driver's hand (LAUNCHER_DESIGN §2.5). The focus ring mirrors with it, so a LEFT press still walks toward whatever is now on the left. The centre column is symmetric and never moves.
Auto cannot infer this on this platform, and says so. Sys_CarType is the model index
within Sys_Vehicle_deries (RAV4 = 2 under Toyota = 1, CAR_API §2.3), the same on every market,
and the steering side is a CAN-box car setting that canbus2 keeps in an in-process map for its
console page — no SysVar, no broadcast. So data/Reachability.kt ships an empty RHD table
that must stay empty, Auto always resolves to LHD, and the settings screen prints the raw
Sys_CarType next to the override so a user can confirm the vehicle profile.
Vehicle dashboard (ui/DashboardScreen.kt, status bar ▸ speedometer icon). The launcher had
accumulated vehicle signals across five surfaces and nowhere to see them together. Speed, outside
temperature, steering, ignition, parking radar and a session timer, on one screen.
Every tile carries a provenance line, because these signals are not equally trustworthy and a dashboard that renders a guess identically to a confirmed reading is worse than no dashboard:
| Tile | Source | Status |
|---|---|---|
| Speed | CarEvents.speedKmh: raw body bus (SpeedSource.BUS), GPS as fallback; canbus2's MCU_CAR_CAN_INFO byte[0] is decoded too but never wins (CAN_SPEED_TRUSTED) |
the bus is confirmed against the ECU's own PID 0x0D; the MCU digest is closed as wrong |
| Outside temp | CAN_CAR_OUT_SIDE_TEMP_EVT |
confirmed, one unit-suffixed String extra |
| Steering | 0x11 decode of MCU_MSG_CAN_ALL_INFO, ZXW_CAN_WHEEL_TRACK_EVT as fallback |
raw/14 scale confirmed; which side is negative UNVERIFIED |
| Ignition | ACTION_ACC_OPEN_CLOSE_EVT |
confirmed |
| Radar | MCU_CAR_CAN_RADAR_INFO |
bank layout + distance codes confirmed; left→right order UNVERIFIED |
| Session timer | measured here | not the car's trip computer |
The steering tile shows no number. The value is the OEM raw/14 scale, never calibrated to
degrees, and which side a negative value means is unverified, so printing "42°" would invent
precision we do not have; a bar that leans the way the wheel leans is exactly as much as the
signal supports. CAN_CAR_TIRP_INFO is never broadcast, so the real trip computer is not readable
without decoding frame 0x13 ourselves — hence "this session".
Driver profiles (data/DriverProfilesStore.kt, status bar ▸ account icon — two taps from
Home, as §3.0 asks). A profile bundles theme, favourites, quick-launch order and reachability.
Applying one writes through to the stores that already own each setting rather than becoming a
second source of truth, so a profile can never disagree with the live value. Saving is
parked-only (it needs the keyboard); applying is not — being unable to restore your own layout
while moving would be worse than the single tap.
Gateway UI mode (carlib/GatewayHandshake.kt). Not a handshake: the gateway delegates its
day/night decision to the launcher. We send ACTION_LAUNCHER_TO_EVENTCENTER_UIMODE_EVENT with
the int extra Extra_Day_Night_UiMode (1 day, 2 night; 0 = follow headlamps, which is what a
missing or boolean extra decodes to — the v3.0 bug). The gateway→launcher half is a request to
echo the same int; unanswered, the gateway applies it itself after 2 s (CAR_API §6.2). Theming
does not read this class; it follows the headlamps via LAMP_STATUS.
The roadmap gated these on STATUS goal #3 proving RAV4 climate is CAN-writable. That test has not
been run, and STATUS still reads "Investigating whether RAV4 climate is CAN-controllable or
display-only (likely display-only)." carlib now carries ClimateControl, which sends the CAN
app's own CAR_AIR_KEY_KEY button broadcast (CanUtils.CAR_AIR_KEY_* values, decompile
CarAirClickWithVoice.java:432,462), but nothing in the UI calls it and it is unverified on the
car. The Climate settings screen continues to write only vendor configuration SysVars (panel
type, bus baud, seat heat, units) — never a live temperature or fan command.
§3.0 asks for a week of daily driving with zero crashes before the release is called done. None of this has run on the head unit at all. Treat v3.0 as feature-complete and unvalidated.
The vendor's platform signing key is confirmed unobtainable, so the launcher can never
be a platform-signed system app and the old "install to /system/priv-app" tier is closed
for good. Magisk root plus permissive SELinux buys back most of what CAR_API §6.4 reserved
for signature-level access. This is that tier.
Everything below degrades silently without root — no dialog, no error, the v2.5 behaviour stands. Settings ▸ Root tier shows what is actually working.
STEER_WHEEL_INFOR and the day/night backlight events are sent with
com.szchoiceway.permission.broadcast (CAR_API §1.1), almost certainly signature. AMS's
checkComponentPermission short-circuits to GRANTED for uid 0 before it consults granted
permissions, so a root process receives them without holding anything.
carlib/RootBroadcastHelper is that process: our own APK's dex run under
su as a bare app_process, registering a receiver on the system context and printing one
line per event. carlib/RootBroadcastBridge reads those lines back and feeds them into the
existing CarEvents flows, so no consumer changed — swcKeys, reverse and dayNight
simply become reliable. CarEvents.rootCapture goes true on the first captured event.
Parsing logcat was the other option and was rejected: AOSP's event log records broadcast
discards and receiver finishes, never a dispatch carrying extras — and an SWC press is
nothing but its extras. Guessing at undocumented vendor debug lines to recover a key index
would mis-decode steering-wheel keys, which is worse than not having them.
SysVar.putString forked a su per write, which the settings suite made audible on every
slider tick. carlib/RootSession holds one su open and serialises commands over its
stdin, behind the existing RootShell.exec API (libsu → RootSession → su -c).
The shell-injection protections are unchanged. RootSession only changes the transport
of an already-built command; callers still single-quote every interpolated value (now via
the one shared RootShell.quote) and still SQL-escape what goes inside a --where. A
command containing a newline would split into two root commands on a stdin channel, so such
a command is refused and falls back to su -c, where a newline is safely inside one argv
entry.
Settings ▸ Root tier ▸ Hide vendor nav bar sets the SysVar Sys_Customer_NaviBar_Height_Key
to 0 (the factory page's own "No bottom bar" option) — the persistent config the gateway reads
for itself at boot and re-applies live on changeSetup, so it survives a reboot. The gateway
honours the key only with Sys_Landscape = 1, and the toggle says so when that is not the case.
The two earlier keys were wrong: SYS_SHOW_TOOL_NAVI_BAR_WND is a system property the gateway
writes as a mirror for skins 108/126/127 and nobody reads, and Sys_Statusbar_Icon_Config_Key
has no reader in any vendor package. No SysVar hides the status bar; the gateway forces it on
in every branch. The safeties stay: the toggle only writes a key that already exists in the
live table, records the original height before the first hide, and writes it back verbatim off.
pm disable-user com.szchoiceway.customerui, behind a destructive confirm (which the v2.5
parked-only lock withholds while the car is moving).
The vendor launcher is not an Android HOME app — no activity of it declares
category.HOME (../CUSTOMERUI_NOTES.md §0) — so disabling it cannot orphan the HOME role.
The real risk is that the gateway inflates its status bar and side-window layouts out of
that package via createPackageContext (CUSTOMERUI_NOTES §3g), and how it copes with the
package being gone is untested.
Recovery, in order of how little it needs to work:
-
Automatic, no action needed. Disabling arms a rollback before it disables, in a detached root shell that init reparents:
sleep 180; [ -f /data/local/tmp/carlauncher_keep_sole_home ] \ || pm enable com.szchoiceway.customerui; \ rm -f /data/local/tmp/carlauncher_keep_sole_home
The launcher can be killed, ANR'd or uninstalled and the vendor launcher still comes back after 180 s. Tapping Keep it disabled within that window writes the keep-file and stands the rollback down.
The one gap: a reboot or ACC power-off inside the 180 s kills that shell and the rollback never fires. Use step 2 or 3.
-
Over adb, with the unit powered and USB connected:
adb shell pm enable com.szchoiceway.customeruiThis needs no working launcher UI and no Magisk prompt —
pm enableruns as the shell user. This is the instruction the in-app dialog quotes, verbatim. -
On the unit, with no adb. Any terminal app with root:
su -c 'pm enable com.szchoiceway.customerui'If our launcher itself is what is broken, press HOME and pick another home from the chooser first — our HOME registration is one of several, not exclusive.
-
Last resort. The disable is per-user state, so a factory reset restores it; the APK was never removed (
disable-user, notuninstall), so there is nothing to reinstall.
app/src/main/baseline-prof.txt + androidx.profileinstaller give the cold-start path an
ART profile — the side-loaded install has no Play install step to deliver one. The profile is
hand-written rather than generated from a macrobenchmark run, because we have one head unit
and it is a car; the header of that file records the trade-off and what to do instead once a
device run is possible.
.gitea/workflows/launcher-ci.yml gains a cold-start job that installs the debug APK on
an API 33 emulator, applies the profile with pm compile -m speed-profile, and fails if the
median of five am start -W runs exceeds a budget. That number is an emulator figure and
must never be quoted as a head-unit one — it is a regression tripwire, set loose until real
runs exist to calibrate it.
The Jellyfin Android client is just another MediaSession, so the media stack already surfaced it
with no work — the MediaCard source chip names it and the transport controls drive it. v2.7 adds
the two things that were missing: a quick-launch preset (media/JellyfinApp.pinFirst moves the
tile to the front of the driver's thumb column when a client is installed — ordering only, nothing
new is added to the drawer) and a parked-only "continue watching" shelf.
What the shelf is, precisely. It lists what this head unit has played, recovered from the
Jellyfin app's MediaSession metadata and persisted in data/WatchHistoryStore. It is not the
server's Continue Watching row. That row is GET /Users/{id}/Items/Resume behind authentication,
and reaching it would mean this APK carrying a private-network URL and a credential that belong
to the owner, not to a launcher. No API client was written and none should be. The screen
says so in its own subtitle, because a shelf that implied it had talked to the server would make the first
missing episode read as a bug rather than a boundary.
Consequences, stated once so nobody re-discovers them:
- Anything watched on a phone or a TV never touched this device's media stack, so it is not here.
- Tapping a row opens Jellyfin at its home screen. Resuming a specific item needs the server item GUID, which needs credentials — there is no deep link to build.
- The position/duration pair is whatever the session last published. A player that stops updating on pause leaves the row slightly stale. It is a hint, not a bookmark. Rows whose session published no duration say so rather than drawing a bar at zero.
- Only Jellyfin sessions are recorded. The media stack sees every player on the unit, and a "continue watching" shelf full of radio adverts would be worse than an empty one.
The candidate packages (org.jellyfin.mobile, org.jellyfin.androidtv) are upstream application
IDs; which one — if either — is installed on this unit is GUESSED and resolved at runtime.
notif/ShelfListenerService is a third NotificationListenerService (the media one is empty by
design, the nav one reads only Maps). It keeps at most 40 recent notifications in memory only,
never on disk, and drops what does not belong on a shelf: our own, ongoing ones (media transport
and Maps' turn-by-turn already have cards; sync bars would pin themselves to the top forever),
group summaries, and anything with neither title nor text.
Per-app filtering is a deny list (data/NotificationFilterStore) applied at render time, so
un-muting an app brings its already-captured notifications straight back. An allow list would make
a newly installed app silently invisible, which is the failure mode where nobody notices the
feature is dropping things.
No notification actions, no reply. Those are PendingIntents that can do anything, and firing arbitrary app intents off a launcher shelf is not something to do with someone sitting in a car. A row does two things: open the source app, or dismiss.
Parked-only, and the listener needs the same grant as the other two:
adb shell cmd notification allow_listener com.ripostelabs.carlauncher/com.ripostelabs.carlauncher.notif.ShelfListenerServiceui/keyboard/CarKeyboard.kt is the v2.3 search QWERTY extracted and given a shift latch
(off → one-shot → locked) and a symbol layer. ui/keyboard/CarTextField.kt wraps it: the field on
the page is a display-only tile, and tapping it opens a full-screen editor that owns the keyboard.
Nothing in the app is a focusable text field any more, so the vendor IME has no way to appear —
which was the point. It ignores night mode, cannot be themed, and eats half of a 720px screen.
Every text field now themes with CarTheme: app search, the theme name and hex fields, the SysVar
browser's search box and its raw value editor.
What that costs, honestly: no caret placement, no selection, no clipboard, no autocomplete. Editing
is append-and-backspace. The symbol layer is curated (!@#$%^&*()-_=+[]{}|\,.:;'"?/), so angle
brackets, tilde and backtick have to be pushed in over adb.
Motion gating comes free: CarTextField is inert while the parked-only lock holds and an open
editor abandons the edit if the car pulls away. Every text field in the app is gated by
construction rather than by remembering to wrap each one.
Import / export (data/ThemeTransfer) writes a theme as JSON — the same codec ThemeStore
persists with, so a file and a stored theme cannot drift — into the app's external files directory:
adb pull /sdcard/Android/data/com.ripostelabs.carlauncher/files/themes/
adb push mytheme.json /sdcard/Android/data/com.ripostelabs.carlauncher/files/themes/That path needs no runtime permission on API 33. Downloads was rejected (scoped storage makes a
plain File write there need MediaStore or MANAGE_EXTERNAL_STORAGE — a lot of permission surface
for a colour file) and so was ACTION_CREATE_DOCUMENT (another un-theme-able system screen, which
is the problem this release exists to reduce). Built-ins export too: pull a preset, edit the hex on
a real keyboard, push it back. An import always lands as a new user theme with a fresh id, so a
hand-edited file cannot shadow a preset or overwrite something already on the unit.
Clock day/night. CarEvents.illuminationSeen (new) latches true the first time an
ACTION_DAY/NIGHT_BACKLIGHT_CHANGED broadcast actually arrives. dayNight alone could never tell
"the car said day" from "the car said nothing" — and on a normal install it says nothing, because
those broadcasts ride com.szchoiceway.permission.broadcast (very likely signature, CAR_API
§1.1). So the unit sits in day colours at midnight.
Settings ▸ Launcher ▸ Clock day / night offers two hours (default 19:00 → 07:00) used by a new
DayNightMode.CLOCK, plus an opt-in fallback that applies those hours in Auto only while
illuminationSeen is false. Off by default on purpose: switching it on for every existing
install would start dimming screens at dusk without being asked. The section states on screen
whether the car has actually reported illumination this session, for the same
diagnosis-over-guesswork reason as the v2.5 motion status row.
No solar calculation. Real civil twilight needs a date and a position, and this launcher's position comes from the same GPS the motion gate has to fail open around — no fix in a garage, none at power-on, none at all without the location grant. A window the driver sets is honest about being an approximation; a sunset calculation would be wrong exactly when it matters.
- A real Jellyfin "continue watching" feed. Needs the server URL and a credential. Out of scope by instruction and by judgement; see above.
- Per-item resume / deep links into Jellyfin. Same reason — item GUIDs come from the API.
- Notification actions and inline reply. Deliberate, see above.
- Anything requiring the head unit. None of v2.7 could be run on the physical device from
here. What is genuinely unverified: whether a Jellyfin client is installed at all and under
which package; whether
cmd notification allow_listenergrants the third listener as reliably as the first two; how much of the shelf survives the vendor's own notification handling; and whether the extracted keyboard's key sizes still read well on the real 240dpi panel.
The RAV4's wheel keys reach the head unit as CAN frame 0x11 (bArr[4] = key id, bArr[5]
= 1 on every frame while held, 0 on release), and the vendor CAN app throws the duration away:
it emits one MCU key on the release frame whatever the hold
(HiworldCanParseToyota.java:831-891). The launcher already receives those raw frames
(MCU_MSG_CAN_ALL_INFO, the steering decode), so carlib/WheelGestures.kt reads the key byte off
the same decode and turns the run of frames into one of Press, LongPress (held 600 ms,
emitted while still held) or DoublePress (second press within 400 ms of a release). A gap of
more than 300 ms with no frame is read as a release. VOL± are ignored: the CAN app owns their
auto-repeat. Ids 8/13 and 9/14 both mean PREV/NEXT and are folded.
Settings ▸ Wheel gestures binds a hold and a double press per key (NEXT, PREV, MODE, PLAY/PAUSE,
TALK, RETURN, MUTE, VOICE) to one of: seek ±30/10 s, next/prev track, play/pause, open
Media/Radio/Home, radio seek up/down, next preset, claim radio, hand audio back to Android, Siri
(Zlink 1500), navigation (Zlink Maps 1504 while CarPlay reports connected, else the Nav card's
app), mute toggle, the vendor voice assistant (ZXW_CAN_KEY_EVT 116, the CAN app's own path).
Media actions go through the active MediaController; seek is seekTo(position ± delta)
clamped to the track. Defaults: NEXT hold = +30 s, PREV hold = −10 s, PLAY/PAUSE hold = mute,
RETURN hold = Home; MODE and TALK holds ship unbound (their plain action runs inside the gateway
and cannot be swallowed, so binding them means "source switch AND …"); every double press =
nothing.
The collateral rule. The vendor reports the key on release, so by the time a second press can
be recognised the first press has already done its plain job (skipped a track, switched source). A
double-press action always lands on top of that, which is why none are bound by default. A hold
has no such collateral on the launcher's own screens: after a LongPress the vendor's key for the
same button, arriving within 1.5 s as an injected KeyEvent (NEXT/PREV/PLAY/RETURN) or as
MCU_KEY_INFOR, is dropped (carlib/WheelKeySwallow.kt, one drop per path per hold). Two limits:
with a third-party app in front (Spotify, CarPlay) the injected key goes to that window and
lands there; and MODE (gateway switchMode()), MUTE (sendSystemKey(12)), TALK and VOICE act
inside the gateway, so a hold on those always carries the vendor's short action too.
Nothing above is verified on the car. The ~100 ms frame period behind the 300 ms gap is inferred from the CAN app's per-frame counter; the settings screen's "Last gesture" line is the on-car check.
IEventService.aidldeclares only a subset of methods and its transaction ordinals almost certainly do not match the real service — regenerate from the decompiledIEventService.javapreserving method order before relying on any call.ICallbackfn.aidlsignature is a placeholder; verify against the device.- Volume is pushed: the gateway broadcasts
MCU_MSG_MAIL_VOL((mute ? 0x80 : 0) | vol) on every MCU volume/mute report, decoded byCarEvents.volume. The status-bar chip's AIDL poll stays only until the first push lands. There is no LocalSocket;SYSTEM_VOLUME:rides theZXW_MESSAGE_TO_ICCOMMUNICATIONbroadcast as text. - Numeric speed.
CAN_SPEED_TRUSTEDstays false, and now stays false for good. canbus2'sMCU_CAR_CAN_INFOdigest ([speed, rpmH, rpmL]) is decoded intoCarEvents.canSpeedKmhand would outrank GPS viaCarEvents.pickSpeed, but the 2026-09-09 drive closed every MCU candidate:0x32never tracked road speed,0x17never arrived in 996 paired samples, and0x13spans 0..175 while the car holds a steady 16 km/h. The verified source is the raw Toyota body bus —RawCanDecoderon0x361,0x498,0x0B4and0x0AA, checked against the car's own ECU over OBD PID0x0Dfrom 0 to 60 km/h.CarEvents.speedKmhprefers that bus reading and falls back to GPS, and the parked-only gate runs on it — asSpeedSource.BUS, its own source, never by promoting the digest. A test pins that: the digest loses to the bus under either trust setting. - Reverse camera feed —
ReverseOverlayis a black placeholder; embed aSurfaceViewbound to the reverse video input, or hostcom.szchoiceway.view.BackCarActivity. - Climate widget — reads the mirrored
CarAirStateparcel; unverified on the car. Rdo_MyFavorite0..5write-back — the encoding is known (freq | (am ? 0x10000 : 0),RadioTuning.encodeVendorFavorite) and the slots are recallable from RadioScreen; writing our presets into them is a product decision, not a research item any more.- Radar sensor order — bank layout and distance codes are from the decompile; the left→right order within a bank is still UNVERIFIED. Run the capture, then set the layout-confirmed flag. Until then the maneuvering side-strips never draw.
- Steering side — not recoverable from SysVar (
Sys_CarTypeis a model index). The reachability mirror's Auto mode stays LHD; only a CAN-box console read could ever feed it. - Vendor
sendModevalue table — recovered (OEM_SYSTEM.md§3.8,data/SrcModeNames); MediaScreen does not yet use it to switch the car between Bluetooth / USB / the built-in player. - Steering angle units — turn lock to lock with the dashboard open and read the extremes off the raw value; the indicator currently assumes ±540 and saturates.
- Trip computer —
CAN_CAR_TIRP_INFOis never broadcast; canbus2 keeps frame 0x13 (range, average speed, elapsed) in its own EventBus. Decoding it fromMCU_MSG_CAN_ALL_INFOwould replace the dashboard's session timer. - Profile renaming — captured profiles get a generated name until v2.7's in-app keyboard
(
CarTextField) is on this branch.






