Skip to content

Latest commit

 

History

History
835 lines (675 loc) · 53 KB

File metadata and controls

835 lines (675 loc) · 53 KB

Car Launcher (com.ripostelabs.carlauncher)

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.

Screens

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.

Home, on the emulator farm at the head unit's 1920×720 Home, on the emulator farm at the head unit's 1920×720
Settings hub on the real unit: Accessories, Games, CAN frame capture Settings hub on the real unit: Accessories, Games, CAN frame capture
CAN frame capture on the real unit: the adapter's firmware banner read over USB host CAN frame capture on the real unit: the adapter's firmware banner read over USB host
Accessories after loading the pushed config Accessories after loading the pushed config
A sequence running against the virtual board A sequence running against the virtual board
Games, listing the installed RetroArch build Games, listing the installed RetroArch build
RetroArch 1.22.2 launched from the Games page RetroArch 1.22.2 launched from the Games page

Module structure

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

Build

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:assembleDebug

The 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.

carlib pulls com.github.topjohnwu.libsu:core from JitPack. If the network blocks JitPack, RootShell still works via its pure-ProcessBuilder su -c fallback — you can drop the libsu line from carlib/build.gradle.kts in that case.

Deploy

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).

Which APK is in the car

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 notes (see CAR_API.md §6.4)

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) ⚠️ only if granted 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

Raw vehicle bus (USB CAN)

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 with UsbManager and does the bulk transfers itself. A USB_DEVICE_ATTACHED filter 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 capture shows 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.

Accessories (car network)

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.

Games

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.

Settings suite (v1.1 → v2.0)

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 IEventService AIDL.
  • 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.

Vendor apps shadow

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.

Motion awareness (v2.5)

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_LOCATION

Motion budget

Nothing 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.

Media & Radio screens (v2.6)

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.

What the firmware does not have

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 no IEventService method 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 in mRadioPSName and returns it there, so the screen shows the station name. The genre is getRadioPTYNum (index into a 32-entry PTY table). There is no RT getter. ZXW_RADIO_INFO_EVT is sent by the gateway on band/frequency change with int extras RadioBndNum, RadioTuneNum, RadioCurFreq — nothing the getters do not already say.
  • Scan is a key. getRadioAMSState / getRadioAPSState only 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.

CarPlay deep links (RAV4-52)

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).

SWC completeness & radar truth (v2.8)

The whole app is drivable from the wheel

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 vendor STEER_WHEEL_INFOR broadcast sends a down and an up and nothing between, so a held key moved the ring once; real KeyEvents 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. Every clickable is 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 a KeyEvent, so they never reached the composition. The bridge synthesises the KeyEvent through Window.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.

Radar: the layout is still UNCONFIRMED

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:

  1. Park. Engage reverse so the MCU starts broadcasting, and open the capture screen.
  2. Press Reset baseline with nothing near the car.
  3. 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/changes are for — a byte that never moved stays dimmed.
  4. Repeat per corner. The offsets that moved, in the order they moved, are the layout.
  5. If the number falls as the obstacle nears, the MCU sends distance rather than a bar count and RadarState.proximity needs inverting.
  6. Write table to logcat dumps the result under tag RadarCapture for adb 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.

Reachability mirror (LHD/RHD)

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.

The cockpit release (v3.0)

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.

HVAC write controls — sender only, no UI

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.

Stability bar — not claimable

§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.

Root-native tier (v2.9)

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.

Protected broadcasts, captured as root

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 changedswcKeys, 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.

Persistent root write channel

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.

Vendor nav bar

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.

Sole-HOME mode, and how to get back

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:

  1. 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.

  2. Over adb, with the unit powered and USB connected:

    adb shell pm enable com.szchoiceway.customerui

    This needs no working launcher UI and no Magisk prompt — pm enable runs as the shell user. This is the instruction the in-app dialog quotes, verbatim.

  3. 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.

  4. Last resort. The disable is per-user state, so a factory reset restores it; the APK was never removed (disable-user, not uninstall), so there is nothing to reinstall.

Startup

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.

Content & comfort (v2.7)

Jellyfin ("jellybelly")

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.

Notification shelf

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.ShelfListenerService

The keyboard, everywhere

ui/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.

Theme quality of life

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.

Not built, and why

  • 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_listener grants 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.

Wheel gestures

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.

Known TODOs

  • IEventService.aidl declares only a subset of methods and its transaction ordinals almost certainly do not match the real service — regenerate from the decompiled IEventService.java preserving method order before relying on any call.
  • ICallbackfn.aidl signature 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 by CarEvents.volume. The status-bar chip's AIDL poll stays only until the first push lands. There is no LocalSocket; SYSTEM_VOLUME: rides the ZXW_MESSAGE_TO_ICCOMMUNICATION broadcast as text.
  • Numeric speed. CAN_SPEED_TRUSTED stays false, and now stays false for good. canbus2's MCU_CAR_CAN_INFO digest ([speed, rpmH, rpmL]) is decoded into CarEvents.canSpeedKmh and would outrank GPS via CarEvents.pickSpeed, but the 2026-09-09 drive closed every MCU candidate: 0x32 never tracked road speed, 0x17 never arrived in 996 paired samples, and 0x13 spans 0..175 while the car holds a steady 16 km/h. The verified source is the raw Toyota body busRawCanDecoder on 0x361, 0x498, 0x0B4 and 0x0AA, checked against the car's own ECU over OBD PID 0x0D from 0 to 60 km/h. CarEvents.speedKmh prefers that bus reading and falls back to GPS, and the parked-only gate runs on it — as SpeedSource.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 feedReverseOverlay is a black placeholder; embed a SurfaceView bound to the reverse video input, or host com.szchoiceway.view.BackCarActivity.
  • Climate widget — reads the mirrored CarAirState parcel; unverified on the car.
  • Rdo_MyFavorite0..5 write-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_CarType is a model index). The reachability mirror's Auto mode stays LHD; only a CAN-box console read could ever feed it.
  • Vendor sendMode value 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 computerCAN_CAR_TIRP_INFO is never broadcast; canbus2 keeps frame 0x13 (range, average speed, elapsed) in its own EventBus. Decoding it from MCU_MSG_CAN_ALL_INFO would 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.