6 Commits
Author SHA1 Message Date
nilsandClaude Opus 5.5 e90031d6b0 App 2.40.3: the signal recorder listens to the assistance properties
The ACC recording of 2.32.0 only polled, and the AAD_* properties read
their defaults. The head unit's own assistance manager never polls them:
it subscribes to each for change events, and a vendor HAL may fill a
value only while someone listens. EVHardware gains a read-only
listenToProperties and AssistBlock (global vendor properties 0x21x0A1xx,
all 108 AAD_* ids); the recorder subscribes to that block for its three
minutes and lists the properties heard, those with changing values first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:55:26 +01:00
nilsandClaude Opus 5.5 f7c489858e App 2.40.2: the Grid map moves on the GPU
The map was hardware-rendered, but every frame recomputed every road
point, rebuilt every path and drew the big roads three times, at 20
frames a second, and the car jumped with each GPS fix. Now it is its own
view under the Grid (MapLayerView, a hardware layer): drawn once into a
GPU texture and only redrawn when tiles change or after about 230 m;
each display frame only sets its translation and rotation. The car
glides between fixes, and the Grid over the map redraws only with the
readings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:32:50 +01:00
nilsandClaude Opus 5.5 c3c47f5159 App 2.40.1: an update always reaches the installer screen
The car refuses the installer session, and only a refusal thrown at
once opened the installer screen; one that came back through the
session's result ended in a toast, so the update downloaded and then
nothing seemed to happen. Now any result but success or the driver's
cancel opens the installer screen with the verified APK, as does a
confirmation that cannot be shown, and a session silent for 20 s is
abandoned for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:18:31 +01:00
nilsandClaude Opus 5.5 9ef058cb44 SPECS: app 2.40.0 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:12:47 +01:00
nilsandClaude Opus 5.5 69b1adee2b App 2.40.0: Grid with map straight from above, full height
The map fills the whole screen, top-down and heading up, about 400 m
ahead and 450 m to each side, over a ground grid that turns with it.
The speed moves to a panel under the gears; the battery wall, the
clock, the gears and the disc stand on dark panels over the map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:12:06 +01:00
nilsandClaude Opus 5.5 90c2c75fc6 SPECS: app 2.39.2 published
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-27 00:06:51 +01:00
22 changed files with 750 additions and 219 deletions
+2 -2
View File
@@ -76,12 +76,12 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
| Tab | Contents | | Tab | Contents |
|---|---| |---|---|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) | | Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
| Consumption / Charging | period totals, daily log, charging sessions and curve | | Consumption / Charging | period totals, daily log, charging sessions and curve |
| Live data | on/off, ABRP API key and user token, upload frequency | | Live data | on/off, ABRP API key and user token, upload frequency |
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import | | Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About | | Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid (and Grid with map, sharing Grid's choice) and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log | | Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server 15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
@@ -0,0 +1,24 @@
package com.evsuite.hardware
/**
* The block of vendor properties the head unit's driver-assistance manager
* (`CarAdvancedAssistedDrivingManager`, its `AAD_*` ids) lives in: global vendor properties
* `0x21x0A1xx`, of any value type — `0x2140A101` (int) to `0x2160A15B` (float, `AAD_EPTCCTRGTSPD`,
* by its name the cruise control's target speed) and the rest of the same block. Used to choose
* what the signal recorder listens to; nothing here is ever written. Pure, unit-tested.
*/
object AssistBlock {
private const val GROUP_MASK = 0xF0000000.toInt()
private const val GROUP_VENDOR = 0x20000000
private const val AREA_MASK = 0x0F000000
private const val AREA_GLOBAL = 0x01000000
private const val BLOCK_MASK = 0x0000FF00
private const val BLOCK = 0x0000A100
/** Whether [propertyId] is a global vendor property in the assistance block. */
fun contains(propertyId: Int): Boolean =
propertyId and GROUP_MASK == GROUP_VENDOR &&
propertyId and AREA_MASK == AREA_GLOBAL &&
propertyId and BLOCK_MASK == BLOCK
}
@@ -320,6 +320,105 @@ object EVHardware {
ignitionCallbacks.remove(callback) ignitionCallbacks.remove(callback)
} }
// -------------------------------------------------------------------------
// Listening to properties (the app's signal recorder) — read only
// -------------------------------------------------------------------------
/** A subscription made by [listenToProperties]; [close] ends it. */
class PropertyListening internal constructor(
/** Properties subscribed to, and those the car service refused. */
val subscribed: Int,
val refused: Int,
private val stop: () -> Unit,
) : AutoCloseable {
override fun close() = stop()
}
/**
* Subscribes to every CarPropertyManager property whose id [admit] accepts, the way the
* head unit's own managers do (`CarAdvancedAssistedDrivingManager` registers each of its
* `AAD_*` ids for change events and never polls them), and hands each change event to
* [onEvent] as (property id, area, value as text) on the car service's thread. A vendor HAL
* may fill a property from the car network only while someone listens, so a poll alone can
* read its default. ON_CHANGE properties are asked for every change, CONTINUOUS ones for
* 1 Hz within their limits. Read only: nothing is set. Null when there is no
* CarPropertyManager or no way to subscribe; [PropertyListening.close] unsubscribes.
*/
fun listenToProperties(admit: (Int) -> Boolean, onEvent: (Int, Int, String) -> Unit): PropertyListening? {
val cpm = sCarPropertyManager ?: return null
return try {
val register = cpm.javaClass.methods.firstOrNull {
it.name == "registerCallback" && it.parameterCount == 3
} ?: return null
val unregister = cpm.javaClass.methods.firstOrNull {
it.name == "unregisterCallback" && it.parameterCount == 1
}
val callbackType = register.parameterTypes[0]
if (!callbackType.isInterface) return null
val proxy = Proxy.newProxyInstance(callbackType.classLoader, arrayOf(callbackType)) { self, method, args ->
when (method.name) {
"onChangeEvent" -> {
val cpv = args?.getOrNull(0)
if (cpv != null) {
try {
val type = cpv.javaClass
val id = type.getMethod("getPropertyId").invoke(cpv) as Int
val area = type.getMethod("getAreaId").invoke(cpv) as Int
onEvent(id, area, com.evsuite.hardware.probe.VendorSurfaceRules.render(
type.getMethod("getValue").invoke(cpv)))
} catch (e: Exception) {
AppLogger.d(TAG, "listen: event unreadable: ${e.readFailureReason()}")
}
}
null
}
"onErrorEvent" -> {
val id = args?.getOrNull(0) as? Int
val area = args?.getOrNull(1) as? Int
if (id != null) onEvent(id, area ?: AREA_GLOBAL, "error event")
null
}
"equals" -> self === args?.getOrNull(0)
"hashCode" -> System.identityHashCode(self)
"toString" -> "PropertyListening"
else -> null
}
}
val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null
var subscribed = 0
var refused = 0
for (config in configs) {
if (config == null) continue
val type = config.javaClass
val id = runCatching { type.getMethod("getPropertyId").invoke(config) as Int }.getOrNull() ?: continue
if (!admit(id)) continue
val continuous = runCatching { type.getMethod("getChangeMode").invoke(config) as Int }.getOrNull() == 2
val rate = if (!continuous) 0f else {
val min = runCatching { type.getMethod("getMinSampleRate").invoke(config) as Float }.getOrNull() ?: 0f
val max = runCatching { type.getMethod("getMaxSampleRate").invoke(config) as Float }.getOrNull() ?: 1f
1f.coerceIn(min, maxOf(min, max))
}
try {
if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribed++
} catch (e: Exception) {
refused++
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
}
}
AppLogger.i(TAG, "listening to $subscribed properties ($refused refused)")
PropertyListening(subscribed, refused) {
try {
unregister?.invoke(cpm, proxy)
} catch (e: Exception) {
AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}")
}
}
} catch (e: Exception) {
AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}")
null
}
}
/** Unregisters the CarPropertyManager proxy (call from Service.onDestroy). */ /** Unregisters the CarPropertyManager proxy (call from Service.onDestroy). */
fun unregisterIgnitionPropertyCallback() { fun unregisterIgnitionPropertyCallback() {
val cpm = sCarPropertyManager ?: return val cpm = sCarPropertyManager ?: return
@@ -0,0 +1,29 @@
package com.evsuite.hardware
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AssistBlockTest {
@Test
fun `the assistance manager's properties are in the block`() {
// First and last int, the target speed and the object distance (floats).
for (id in intArrayOf(0x2140A101, 0x2140A171, 0x2160A15B, 0x2160A12C, 0x2160A114)) {
assertTrue("0x%08X".format(id), AssistBlock.contains(id))
}
}
@Test
fun `other properties are not`() {
for (id in intArrayOf(
0x2140A0FF, // the block before
0x2140A201, // the block after
0x11400400, // a standard (system) property
0x2340A101, // not global
0x0140A101, // no group
)) {
assertFalse("0x%08X".format(id), AssistBlock.contains(id))
}
}
}
+3 -2
View File
@@ -39,8 +39,9 @@ top. Those are Fatih's reads the car needs and upstream does not have — the ve
ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the charging status ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the charging status
from `0x2140F409`, SoC, range, power, pack voltage and current on SWI69 as on SWI68, and SWI69 from `0x2140F409`, SoC, range, power, pack voltage and current on SWI69 as on SWI68, and SWI69
speed already in km/h — plus translated comments and, from this fork, the gear's raw code speed already in km/h — plus translated comments and, from this fork, the gear's raw code
(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`) and a drive mode write without the binder (`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`), a drive mode write without the binder
fallback (`writeDriveModeProperty`). To take upstream again, merge three ways with fallback (`writeDriveModeProperty`), and read-only listening to properties for the signal
recorder (`listenToProperties`, `AssistBlock`). To take upstream again, merge three ways with
`e4e1158` as the base. `e4e1158` as the base.
The car app links EVHardware into its APK, so the **built app** may only be used and passed on The car app links EVHardware into its APK, so the **built app** may only be used and passed on
+7
View File
@@ -110,3 +110,10 @@ That screen needs a `file://` URI on the car build: a read grant on our FileProv
when it comes from the system user (`not exported from UID 1000`), and the installer reports the when it comes from the system user (`not exported from UID 1000`), and the installer reports the
unreadable file as "There was a problem parsing the package". On the emulator the session still unreadable file as "There was a problem parsing the package". On the emulator the session still
installs silently. installs silently.
The refusal does not always come at once: the platform may also answer a moment later, through
the session's result (`OtaInstallResultReceiver`). Since 2.40.1 that answer opens the installer
screen too, as does a confirmation the platform asks for but cannot show; only **Cancel** on that
screen ends the update there. And if the session says nothing at all for 20 s, the app abandons
it and opens the installer screen. Before 2.40.1 a late refusal ended in a short message only:
the update downloaded and then nothing seemed to happen.
+1 -1
View File
@@ -95,7 +95,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency | | **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import | | **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
| **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About | | **Settings** | Full screen, voice key, Overview style and accent colour, tyre pressure unit; drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P); battery (manual SoH, usable capacity) and charging price; entry on the phone; version, update check, install from USB stick, About |
| **Logs** | Signal recorder (records every value the head unit offers for 3 minutes and lists what changed, to find where the car publishes something; saved to a USB stick too), update log (every install step), upload log | | **Logs** | Signal recorder (records every value the head unit offers for 3 minutes, listens to the driver-assistance values for every change they report, and lists what changed, to find where the car publishes something; saved to a USB stick too), update log (every install step), upload log |
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the - **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
voice key** on the steering wheel and the app comes to the front (switch on the Settings voice key** on the steering wheel and the app comes to the front (switch on the Settings
+54 -1
View File
@@ -78,7 +78,9 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
(`AAD_EPTCCTRGTSPD` `0x2160A15B` 0.0, `AAD_ACC_OBJ_*` 0.0 / -128.0, modes 0). A signal recording (`AAD_EPTCCTRGTSPD` `0x2160A15B` 0.0, `AAD_ACC_OBJ_*` 0.0 / -128.0, modes 0). A signal recording
of all 1,194 values while the set speed was 101, 90 and 80 km/h shows none of those numbers of all 1,194 values while the set speed was 101, 90 and 80 km/h shows none of those numbers
(also tried as m/s, mph and ×2/×10/×16/×64) outside the actual speed, and the ACC buttons send (also tried as m/s, mph and ×2/×10/×16/×64) outside the actual speed, and the ACC buttons send
no key broadcast. Most likely only the instrument cluster has it. no key broadcast. Most likely only the instrument cluster has it. Not yet tried: listening
(subscribing) instead of polling, as the head unit's own manager does — the recorder does it
since 2.40.3; a drive with ACC will tell.
## To do ## To do
@@ -138,6 +140,57 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-27 — App 2.40.3: **the signal recorder listens** (owner: "is there really nothing we
can do about the readout of the cruise control set point"). The 2.32.0 recording only polled.
The head unit's `CarAdvancedAssistedDrivingManager` never polls its 108 `AAD_*` properties: it
registers a listener for each (`registerListener(…, 0.0f)`, on change), and a vendor HAL may
fill a value from the car network only while someone listens — which would read as the
defaults we saw. EVHardware gains `listenToProperties` (read only: the CarPropertyManager's
`registerCallback` per admitted property, rate 0 for ON_CHANGE, 1 Hz within limits for
CONTINUOUS, one proxy, `unregisterCallback` on close) and `AssistBlock` (global vendor
properties `0x21x0A1xx`, which holds all 108 `AAD_*` ids; tested). The recorder subscribes to
that block for its 3 minutes; `SignalRecording.event` counts every event and puts value changes
on the timeline; the file and the Logs tab list the properties heard, those with changing
values first ("heard …"). Emulator (no car service): the recorder notes that listening is not
possible, records, saves the new section; no crash. The subscription itself is the same
reflection as the ignition listener that works on the car, but untried on the car.
- 2026-10-27 — App 2.40.2: **the map moves on the GPU** (owner: "is the map screen gpu
accelerated? it looks a bit slow on update"). It was hardware-rendered, but every frame (20 a
second) recomputed every road point on the main thread, rebuilt all paths, and drew the big
roads three times for the glow; and the car jumped with each GPS fix (1 Hz). Now the map is
its own view under the Grid (`MapLayerView`, `LAYER_TYPE_HARDWARE`): drawn once into a GPU
texture round an anchor and only redrawn when tiles come or go or the car is ~280 layout units
(≈230 m) from the anchor; each display frame (`postOnAnimation`, only while that style is on
screen and the map shows) sets its translation, pivot and rotation, which the GPU composites.
The car glides towards each fix (eased, snaps beyond 200 m), the heading eases; GridView over
the map no longer animates itself (only the readings redraw it), the "far roads fainter" sort
is a fixed dark gradient towards the top. Layer size on the car about 2500 px square (~25 MB).
Measured on the emulator: with SwiftShader (software GPU) old and new both ~57 ms a frame, and
with the host's Intel GPU (`-gpu host`) both run at the display rate (~18 ms) — the emulator
is too fast to show the car's slowness, so the gain on the head unit is reasoned, not
measured. Not tried on the car.
- 2026-10-27 — App 2.40.1: **an update that downloaded but never reached the installer** (owner:
"sometimes … downloads the update but then doesn't start the installer"). The car refuses the
installer session ("Not allowed to install non-system apps on internal storage"); only a
refusal thrown at once led to the installer screen. A refusal that came back through the
session's result was a toast and nothing else. Now any answer but success, the confirmation and
the driver's own cancel opens the installer screen with the verified APK (path and session id
ride on the result intent; `goAsync` for the copy), a confirmation that cannot be shown does
too, and a session silent for 20 s is abandoned for it. Emulator, with a throwaway build whose
session bytes were damaged (refused later, status 4 `INSTALL_PARSE_FAILED_NOT_APK`) and which
read the update from Downloads: the installer screen opened by itself, Install, now 2.40.1; the
update log shows every step. The 20 s watch is untested (no way to make a session stay silent).
Not tried on the car. OTA.md updated.
- 2026-10-27 — App 2.40.0 published (`car-2.40.0`).
- 2026-10-27 — App 2.40.0: **Grid with map straight from above, full height** (owner: 2.39.2
"does not look right"; wanted the map over the whole height and fully top-down). The map is
the whole background in map mode: orthographic, heading up, 1.2 layout units per metre (about
400 m ahead, 125 m behind, 450 m to each side on the car's screen), the car 150 units above
the bottom edge; ground grid every 50 m fixed to the ground; roads from 250 m ahead fainter;
the camera, the sky and the floor are gone there. The speed moves to a panel under the gears
(120 units), and the battery wall, the clock, the gears and the disc get dark panels. The
plain Grid is unchanged. Emulator with the Berlin tiles, both layouts. Not tried on the car.
- 2026-10-27 — App 2.39.2 published (`car-2.39.2`).
- 2026-10-27 — App 2.39.2: **Grid with map seen from higher up** (owner: the map looked like a - 2026-10-27 — App 2.39.2: **Grid with map seen from higher up** (owner: the map looked like a
fish-eye; wanted it more top-down). The map has its own pinhole camera now: 45° down, 300 m fish-eye; wanted it more top-down). The map has its own pinhole camera now: 45° down, 300 m
up, the car on its axis just above the read-outs, the top of the floor about 420 m ahead up, the car on its axis just above the read-outs, the top of the floor about 420 m ahead
+5 -5
View File
@@ -165,11 +165,11 @@ films' other side.
## Grid with map ## Grid with map
The Grid, with the roads round the car from OpenStreetMap (through OpenFreeMap) laid on its floor as light lanes, seen The Grid over a map: the roads round the car from OpenStreetMap (through OpenFreeMap) fill the
from a camera high above and behind the car, tilted down, so the streets keep their shape some whole screen as light lanes, straight from above and turned with the direction you drive, about
400 m ahead, and turned with the direction you drive: motorways glow brightest, then main 400 m ahead and 450 m to each side: motorways glow brightest, then main roads, then the smaller
roads, then the smaller streets, the far ones fainter. A ground grid every 50 m turns and slides streets, the far ones fainter, over a ground grid every 50 m that turns and slides with them.
with them. The car is the chevron on the The speed, the battery wall, the power disc and the clock stand on dark panels over the map. The car is the chevron on the
floor, and between GPS fixes it runs on with the speed. The roads come through the car's own floor, and between GPS fixes it runs on with the speed. The roads come through the car's own
server, which fetches only the map tiles you drive through and keeps them (see the README); the car also keeps server, which fetches only the map tiles you drive through and keeps them (see the README); the car also keeps
the last few hundred tiles itself. Without GPS, the server or the roads, the floor says so and the last few hundred tiles itself. Without GPS, the server or the roads, the floor says so and
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 144 versionCode 148
versionName "2.39.2" versionName "2.40.3"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
+79 -177
View File
@@ -23,12 +23,14 @@ import android.os.SystemClock;
* the trail; the floor stands still when the speed is unread. * the trail; the floor stands still when the speed is unread.
* *
* With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie * With a map ("Grid with map", {@link MapRoads}): the roads round the car from OpenStreetMap lie
* on the floor in perspective, heading up, the bigger ones glowing brighter, with the car * under everything, heading up, the bigger ones glowing brighter, with the car as a light
* as a light marker a little way up the floor; between GPS fixes the car's position runs on * marker low on the screen; between GPS fixes the car's position runs on
* with the speed. The map has its own camera, higher and steeper than the plain * with the speed. The map is then the whole background, straight from above (about
* floor's, so far roads shrink less; the floor starts higher, the speed is a little smaller, its * 400 m ahead, 450 m to each side), over a ground grid (a line every 50 m) that turns and slides
* grid is fixed to the ground (a line every 50 m) and turns with the roads, and the trail makes * with the roads, fading into the dark towards the top. It is its own view under this one
* way for the map. Without a fix, a server or the roads, the plain floor with a line * ({@link MapLayerView}): drawn once into a hardware layer and moved and turned by the GPU each
* display frame, the car gliding between fixes; the speed, the battery wall, the disc and the clock stand on dark panels over
* it, and the trail makes way for the map. Without a fix, a server or the roads, the plain floor with a line
* saying why. * saying why.
* *
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A * Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
@@ -41,14 +43,6 @@ final class GridView extends ClusterCanvasView {
/** Battery segments in the light wall, disc segments, metres per grid tile. */ /** Battery segments in the light wall, disc segments, metres per grid tile. */
private static final int WALL = 20, DISC = 60; private static final int WALL = 20, DISC = 60;
private static final float TILE_M = 4f; private static final float TILE_M = 4f;
/**
* The map's camera: tilted 45° down from about 300 m up, the car on its axis, the top of the
* floor some 420 m ahead; nothing nearer to the lens than NEAR_M is drawn, nothing beyond
* MAP_RANGE_M looked at, and roads from FADE_M ahead are fainter. The ground grid under the
* roads has a line every GRID_M.
*/
private static final float PITCH = (float) Math.toRadians(45), CAM_H = 300f, AHEAD_M = 420f,
NEAR_M = 40f, MAP_RANGE_M = 1300f, FADE_M = 250f, GRID_M = 50f;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
@@ -63,16 +57,10 @@ final class GridView extends ClusterCanvasView {
private final int accent, accentLight; private final int accent, accentLight;
/** Null for the plain Grid. */ /** Null for the plain Grid. */
private final MapRoads map; private final MapRoads map;
/** Per road class, near and far (from {@link #FADE_M} ahead). */ /** The map under this view (a hardware layer, moved by the GPU); null for the plain Grid. */
private final Path[] roads = new Path[RoadTile.CLASSES * 2]; private final MapLayerView mapLayer;
private float heading = Float.NaN; private final Paint fade = new Paint();
/** The pen while a road is added: the last point, and whether it continues from there. */ private int fadeFor = -1;
private float penX, penY;
private boolean penDown;
private Path penPath;
private final Path gridPath = new Path();
/** The camera of this frame: focal length and centre in pixels. */
private float camF, camCx, camCy;
private int main, light, dim, alert; private int main, light, dim, alert;
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */ /** The floor's position within one tile (0..1) and the disc's inner ring angle. */
@@ -87,7 +75,7 @@ final class GridView extends ClusterCanvasView {
GridView(Context context, MapRoads map) { GridView(Context context, MapRoads map) {
super(context); super(context);
this.map = map; this.map = map;
for (int i = 0; i < roads.length; i++) roads[i] = new Path(); mapLayer = map == null ? null : new MapLayerView(context, map);
accent = AccentColor.resolve(context, R.attr.evAccent); accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight); accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE); line.setStyle(Paint.Style.STROKE);
@@ -103,9 +91,24 @@ final class GridView extends ClusterCanvasView {
dim = AccentColor.withAlpha(main, 0x40); dim = AccentColor.withAlpha(main, 0x40);
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F; alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
skyFor = -1; skyFor = -1;
if (mapLayer != null) mapLayer.setColours(main, light);
invalidate(); invalidate();
} }
/** The map layer, for the pane to put under this view; null for the plain Grid. */
MapLayerView mapLayer() {
return mapLayer;
}
@Override
void setValues(ClusterValues values) {
super.setValues(values);
if (mapLayer != null) {
mapLayer.setSpeed(v.speedKmh);
mapLayer.kick();
}
}
@Override @Override
protected void onDraw(Canvas c) { protected void onDraw(Canvas c) {
super.onDraw(c); super.onDraw(c);
@@ -113,18 +116,23 @@ final class GridView extends ClusterCanvasView {
if (w <= 0 || h <= 0) return; if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f); float u = Math.min(h / 640f, w / 1100f);
advance(); advance();
// With a map, the map layer underneath is the whole background (this view stays clear
// there), and the rest stands on dark panels over it.
boolean mapShown = mapLayer != null && mapLayer.isShowing();
float horizon = h * 0.55f;
if (mapShown) {
drawCar(c, w, h, u);
} else {
c.drawColor(0xFF000000); c.drawColor(0xFF000000);
// With a map the floor starts higher, to give the roads room.
boolean mapShown = map != null && map.fix() != null && !map.tiles().isEmpty();
float horizon = h * (mapShown ? 0.5f : 0.55f);
drawSky(c, w, horizon); drawSky(c, w, horizon);
if (mapShown) drawMap(c, w, h, horizon, u); drawFloor(c, w, h, horizon, u);
else drawFloor(c, w, h, horizon, u); }
if (map != null && !mapShown) drawMapState(c, w, h, horizon, u); if (map != null && !mapShown) drawMapState(c, w, h, horizon, u);
if (mapShown) drawPanels(c, w, horizon, u);
drawGears(c, w, u); drawGears(c, w, u);
drawClock(c, w, u); drawClock(c, w, u);
drawSpeed(c, w, horizon, mapShown ? 170f : 210f, u); if (mapShown) drawMapSpeed(c, w, u);
else drawSpeed(c, w, horizon, u);
drawWall(c, horizon, u); drawWall(c, horizon, u);
drawDisc(c, w, mapShown, u); drawDisc(c, w, mapShown, u);
if (!mapShown) drawTrail(c, w, h, u); if (!mapShown) drawTrail(c, w, h, u);
@@ -132,8 +140,8 @@ final class GridView extends ClusterCanvasView {
if (mapShown) drawAttribution(c, w, h, u); if (mapShown) drawAttribution(c, w, h, u);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f; boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
// The map costs more per frame: 20 a second is smooth enough for it. // Over the map only the readings change; the map layer moves itself.
keepAnimating(moving, mapShown ? 50 : 33); keepAnimating(moving && !mapShown, 33);
} }
/** Moves the floor by the distance driven since the last frame, and turns the disc. */ /** Moves the floor by the distance driven since the last frame, and turns the disc. */
@@ -181,157 +189,51 @@ final class GridView extends ClusterCanvasView {
// ---------- Map ---------- // ---------- Map ----------
/** The roads round the car on the floor, seen by the map's camera, with a ground grid. */ /** Over the map: the far roads fading into the dark towards the top, and the car. */
private void drawMap(Canvas c, float w, float h, float horizon, float u) { private void drawCar(Canvas c, float w, float h, float u) {
MapRoads.Fix fix = map.fix(); float carSx = w / 2f, carSy = h - MapLayerView.CAR_FROM_BOTTOM * u;
java.util.Collection<RoadTile> tiles = map.tiles(); if (fadeFor != (int) (carSy + h)) {
if (fix == null) return; fade.setShader(new LinearGradient(0, 0, 0, carSy, 0xB0000000, 0x00000000, Shader.TileMode.CLAMP));
// Heading up: ease round the shorter way; north up until the car has moved. fadeFor = (int) (carSy + h);
float target = Float.isNaN(fix.bearing) ? (Float.isNaN(heading) ? 0f : heading) : fix.bearing;
if (Float.isNaN(heading)) heading = target;
else heading = (heading + (((target - heading) % 360f + 540f) % 360f - 180f) * 0.2f + 360f) % 360f;
// Between fixes the car runs on with the speed, for at most 2.5 s.
double mpu = MapGeometry.metresPerUnit(fix.lat);
float ran = 0f;
if (v.speedKmh != null && !Float.isNaN(fix.bearing)) {
ran = v.speedKmh / 3.6f * Math.min(2.5f, (SystemClock.elapsedRealtime() - fix.elapsedMs) / 1000f);
} }
double run = Math.toRadians(Float.isNaN(fix.bearing) ? 0f : fix.bearing); c.drawRect(0, 0, w, carSy, fade);
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu; // A light-cycle chevron pointing ahead.
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
// The camera: the car where the chevron stands, the top of the floor AHEAD_M beyond it.
float carScreenY = h - 96f * u;
float z0 = CAM_H / (float) Math.tan(PITCH);
camF = (carScreenY - horizon) / (up(z0 + AHEAD_M) - up(z0));
camCy = carScreenY + camF * up(z0);
camCx = w / 2f;
float sin = (float) Math.sin(Math.toRadians(heading)), cos = (float) Math.cos(Math.toRadians(heading));
float m = (float) mpu;
double range = MAP_RANGE_M / mpu;
// The ground grid, fixed to the ground: it turns and slides with the roads.
gridPath.reset();
penPath = null;
double carEast = carX * mpu, carNorth = -carY * mpu;
float offE = (float) (carEast % GRID_M), offN = (float) (carNorth % GRID_M);
int lines = (int) (MAP_RANGE_M / GRID_M);
for (int k = -lines; k <= lines; k++) {
float e = k * GRID_M - offE, n = k * GRID_M - offN;
gridLine(e, -MAP_RANGE_M, e, MAP_RANGE_M, sin, cos);
gridLine(-MAP_RANGE_M, n, MAP_RANGE_M, n, sin, cos);
}
for (Path p : roads) p.reset();
for (RoadTile tile : tiles) {
double ox = (double) tile.x * MapGeometry.EXTENT - carX, oy = (double) tile.y * MapGeometry.EXTENT - carY;
if (ox > range || oy > range || ox + MapGeometry.EXTENT < -range || oy + MapGeometry.EXTENT < -range) {
continue;
}
float fx = (float) ox, fy = (float) oy;
for (int i = 0; i < tile.points.length; i++) {
int[] b = tile.bounds[i];
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
int cls = tile.classes[i];
int[] pts = tile.points[i];
penDown = false;
float pa = 0f, pf = 0f;
for (int k = 0; k < pts.length; k += 2) {
float east = (fx + pts[k]) * m, north = -(fy + pts[k + 1]) * m;
float fwd = east * sin + north * cos, side = east * cos - north * sin;
if (k > 0) segment(roads[cls * 2 + (Math.min(pf, fwd) > FADE_M ? 1 : 0)], pa, pf, side, fwd);
pa = side;
pf = fwd;
}
}
}
c.save();
c.clipRect(0, horizon, w, h);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
line.setColor(AccentColor.withAlpha(main, 0x2A));
line.setStrokeWidth(1f * u);
c.drawPath(gridPath, line);
// Far roads thinner and fainter.
for (int cls = RoadTile.CLASSES - 1; cls >= 0; cls--) {
line.setColor(AccentColor.withAlpha(cls == 0 ? light : main, cls >= 3 ? 0x50 : 0x80));
line.setStrokeWidth((cls == 0 ? 2f : cls == 3 ? 1f : 1.4f) * u);
c.drawPath(roads[cls * 2 + 1], line);
}
line.setColor(AccentColor.withAlpha(main, 0x90));
line.setStrokeWidth(1.5f * u);
c.drawPath(roads[3 * 2], line);
line.setColor(AccentColor.withAlpha(main, 0xD0));
line.setStrokeWidth(2.2f * u);
c.drawPath(roads[2 * 2], line);
glow(c, roads[2], main, 2.6f * u);
glow(c, roads[0], light, 3.4f * u);
line.setStrokeJoin(Paint.Join.MITER);
line.setStrokeCap(Paint.Cap.SQUARE);
c.restore();
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
// The car: a light-cycle chevron pointing ahead.
path.reset(); path.reset();
path.moveTo(camCx, carScreenY - 16f * u); path.moveTo(carSx, carSy - 18f * u);
path.lineTo(camCx + 11f * u, carScreenY + 10f * u); path.lineTo(carSx + 12f * u, carSy + 11f * u);
path.lineTo(camCx, carScreenY + 4f * u); path.lineTo(carSx, carSy + 5f * u);
path.lineTo(camCx - 11f * u, carScreenY + 10f * u); path.lineTo(carSx - 12f * u, carSy + 11f * u);
path.close(); path.close();
fill.setColor(light); fill.setColor(light);
c.drawPath(path, fill); c.drawPath(path, fill);
glow(c, path, light, 1.5f * u); glow(c, path, light, 1.5f * u);
} }
/** The camera's height of a ground point z metres ahead of the spot below it, over its depth. */ /** Dark panels under the battery wall and the clock, so the map does not run through them. */
private static float up(float z) { private void drawPanels(Canvas c, float w, float horizon, float u) {
float cos = (float) Math.cos(PITCH), sin = (float) Math.sin(PITCH); chamfer(16f * u, 78f * u, 360f * u, horizon + 86f * u, 14f * u);
return (z * sin - CAM_H * cos) / (z * cos + CAM_H * sin); fill.setColor(0xC8000000);
c.drawPath(path, fill);
chamfer(w - 210f * u, 12f * u, w - 12f * u, 82f * u, 10f * u);
c.drawPath(path, fill);
// Behind the gears as well.
chamfer(w / 2f - 150f * u, 12f * u, w / 2f + 150f * u, 72f * u, 10f * u);
c.drawPath(path, fill);
} }
/** A grid line between two ground points given east and north of the car, in metres. */ /** The speed over the map: smaller, on a panel under the gears. */
private void gridLine(float e0, float n0, float e1, float n1, float sin, float cos) { private void drawMapSpeed(Canvas c, float w, float u) {
penDown = false; chamfer(w / 2f - 130f * u, 80f * u, w / 2f + 130f * u, 232f * u, 14f * u);
segment(gridPath, e0 * cos - n0 * sin, e0 * sin + n0 * cos, e1 * cos - n1 * sin, e1 * sin + n1 * cos); fill.setColor(0xC8000000);
} c.drawPath(path, fill);
line.setColor(dim);
/** line.setStrokeWidth(1.5f * u);
* One stretch of ground from (side, ahead) a0, f0 to a1, f1 in metres from the car, through c.drawPath(path, line);
* the camera, cut where it comes nearer to the lens than NEAR_M. text.setTypeface(thin);
*/ text.setTextSize(120f * u);
private void segment(Path p, float a0, float f0, float a1, float f1) { text.setTextAlign(Paint.Align.CENTER);
float cos = (float) Math.cos(PITCH), sin = (float) Math.sin(PITCH), z0 = CAM_H / (float) Math.tan(PITCH); glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, 194f * u, light, u);
float za = z0 + f0, zb = z0 + f1; caption(c, "KM/H", w / 2f, 220f * u, u, Paint.Align.CENTER);
float d0 = za * cos + CAM_H * sin, d1 = zb * cos + CAM_H * sin;
if (d0 < NEAR_M && d1 < NEAR_M) {
penDown = false;
return;
}
boolean cut = false;
if (d0 < NEAR_M || d1 < NEAR_M) {
float t = (NEAR_M - d0) / (d1 - d0);
float a = a0 + (a1 - a0) * t, z = za + (zb - za) * t;
if (d0 < NEAR_M) {
a0 = a;
za = z;
d0 = NEAR_M;
cut = true;
} else {
a1 = a;
zb = z;
d1 = NEAR_M;
}
}
float x0 = camCx + camF * a0 / d0, y0 = camCy - camF * (za * sin - CAM_H * cos) / d0;
float x1 = camCx + camF * a1 / d1, y1 = camCy - camF * (zb * sin - CAM_H * cos) / d1;
if (!penDown || cut || p != penPath || x0 != penX || y0 != penY) p.moveTo(x0, y0);
p.lineTo(x1, y1);
penX = x1;
penY = y1;
penPath = p;
penDown = true;
} }
/** Why the map is not there, on the floor. */ /** Why the map is not there, on the floor. */
@@ -391,9 +293,9 @@ final class GridView extends ClusterCanvasView {
// ---------- Speed ---------- // ---------- Speed ----------
private void drawSpeed(Canvas c, float w, float horizon, float size, float u) { private void drawSpeed(Canvas c, float w, float horizon, float u) {
text.setTypeface(thin); text.setTypeface(thin);
text.setTextSize(size * u); text.setTextSize(210f * u);
text.setTextAlign(Paint.Align.CENTER); text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, horizon - 62f * u, light, u); glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, horizon - 62f * u, light, u);
caption(c, "KM/H", w / 2f, horizon - 22f * u, u, Paint.Align.CENTER); caption(c, "KM/H", w / 2f, horizon - 22f * u, u, Paint.Align.CENTER);
@@ -358,6 +358,8 @@ public class MainActivity extends AppCompatActivity {
new QuestView(this), visorView, new EmpireView(this)); new QuestView(this), visorView, new EmpireView(this));
View[] canvasPanes = new View[canvasStyles.size()]; View[] canvasPanes = new View[canvasStyles.size()];
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i)); for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
// Grid with map: its map layer goes under it, in the same pane.
((android.widget.FrameLayout) canvasPanes[canvasStyles.indexOf(gridMapView)]).addView(gridMapView.mapLayer(), 0);
View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane, View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane,
futuristicPane, sciFiPane, cartoonPane, mgPane }; futuristicPane, sciFiPane, cartoonPane, mgPane };
// By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order. // By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order.
@@ -0,0 +1,223 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.os.SystemClock;
import android.view.View;
/**
* The map under "Grid with map": the roads round the car and a ground grid, straight from
* above, heading up — drawn once into a hardware layer (a GPU texture) and then only moved and
* turned, by the view's own translation and rotation, once per display frame. Nothing is
* redrawn while driving until new tiles arrive or the car nears the layer's edge; then it is
* drawn again round the car. {@link GridView} draws everything else on top.
*
* The layer is a square about twice the distance from the car to the farthest screen corner,
* plus a margin, so it covers the screen at any angle; the car sits {@link #CAR_FROM_BOTTOM}
* layout units above the bottom edge.
*/
final class MapLayerView extends View {
/** Pixels (layout units) per metre, the car's height above the bottom edge, grid spacing. */
static final float MAP_SCALE = 1.2f, CAR_FROM_BOTTOM = 150f;
private static final float GRID_M = 50f;
/** How far the car may move from the layer's centre before it is drawn again, in layout units. */
private static final float MARGIN = 280f;
private final MapRoads map;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path[] roads = new Path[RoadTile.CLASSES];
private final Path grid = new Path();
private int main = 0xFF18E6FF, light = 0xFFC8FCFF;
/** The layer's centre (world tile units) and what it was drawn from. */
private double anchorX = Double.NaN, anchorY;
private int drawnVersion = -1;
private float drawnScale;
/** The car as shown, eased towards where it is (world tile units), and the heading. */
private double shownX = Double.NaN, shownY;
private float heading = Float.NaN;
private boolean showing, ticking;
/** The car's speed, from the readings; NaN when unread. */
private float speedMps = Float.NaN;
MapLayerView(Context context, MapRoads map) {
super(context);
this.map = map;
for (int i = 0; i < roads.length; i++) roads[i] = new Path();
line.setStyle(Paint.Style.STROKE);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
setLayerType(LAYER_TYPE_HARDWARE, null);
setVisibility(INVISIBLE);
setWillNotDraw(false);
}
void setColours(int main, int light) {
this.main = main;
this.light = light;
invalidate();
}
/** Whether the map is there to be seen (a fix and roads). */
boolean isShowing() {
return showing;
}
/** Layout units, as GridView uses them, for this size. */
private float unit() {
View parent = (View) getParent();
int w = parent == null ? getWidth() : parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight();
int h = parent == null ? getHeight() : parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom();
return Math.min(h / 640f, w / 1100f);
}
@Override
protected void onMeasure(int widthSpec, int heightSpec) {
int w = MeasureSpec.getSize(widthSpec), h = MeasureSpec.getSize(heightSpec);
float u = Math.min(h / 640f, w / 1100f);
int side = (int) (2 * (Math.hypot(w / 2f, h - CAR_FROM_BOTTOM * u) + MARGIN * u));
setMeasuredDimension(Math.max(side, 1), Math.max(side, 1));
}
/** The car's speed in km/h, or null when unread; GridView passes it with every reading. */
void setSpeed(Float kmh) {
speedMps = kmh == null ? Float.NaN : kmh / 3.6f;
}
/** Starts the frame loop if it is not running; GridView calls it with every reading. */
void kick() {
if (!ticking && isAttachedToWindow()) {
ticking = true;
postOnAnimation(this::tick);
}
}
/** One display frame: where the car is, which way, and the layer moved there. */
private void tick() {
View pane = (View) getParent();
if (pane == null || !pane.isShown()) {
ticking = false;
return;
}
MapRoads.Fix fix = map.fix();
boolean ready = fix != null && !map.tiles().isEmpty() && getWidth() > 0;
if (ready != showing) {
showing = ready;
setVisibility(ready ? VISIBLE : INVISIBLE);
}
if (!ready) {
ticking = false;
return;
}
float target = Float.isNaN(fix.bearing) ? (Float.isNaN(heading) ? 0f : heading) : fix.bearing;
if (Float.isNaN(heading)) heading = target;
else heading = (heading + (((target - heading) % 360f + 540f) % 360f - 180f) * 0.12f + 360f) % 360f;
// Between fixes the car runs on with the speed (at most 2.5 s), and what is shown glides
// there rather than jumping with each fix.
double mpu = MapGeometry.metresPerUnit(fix.lat);
float ran = Float.isNaN(fix.bearing) || Float.isNaN(speedMps) ? 0f
: speedMps * Math.min(2.5f, (SystemClock.elapsedRealtime() - fix.elapsedMs) / 1000f);
double run = Math.toRadians(Float.isNaN(fix.bearing) ? 0f : fix.bearing);
double carX = MapGeometry.worldX(fix.lon) + ran * Math.sin(run) / mpu;
double carY = MapGeometry.worldY(fix.lat) - ran * Math.cos(run) / mpu;
if (Double.isNaN(shownX) || Math.hypot(carX - shownX, carY - shownY) * mpu > 200) {
shownX = carX;
shownY = carY;
} else {
shownX += (carX - shownX) * 0.15;
shownY += (carY - shownY) * 0.15;
}
float u = unit(), px = MAP_SCALE * u;
if (drawnVersion != map.version() || Double.isNaN(anchorX) || drawnScale != px
|| Math.hypot(shownX - anchorX, shownY - anchorY) * mpu * px > MARGIN * u) {
build(shownX, shownY, mpu);
drawnScale = px;
invalidate(); // the only redraw of the layer
}
// The car's spot in the layer goes to its spot on screen, turned round it.
float half = getWidth() / 2f;
float inX = half + (float) ((shownX - anchorX) * mpu) * px;
float inY = half + (float) ((shownY - anchorY) * mpu) * px;
float carSx = pane.getPaddingLeft() + (pane.getWidth() - pane.getPaddingLeft() - pane.getPaddingRight()) / 2f;
float carSy = pane.getHeight() - pane.getPaddingBottom() - CAR_FROM_BOTTOM * u;
setPivotX(inX);
setPivotY(inY);
setRotation(-heading);
setTranslationX(carSx - getLeft() - inX);
setTranslationY(carSy - getTop() - inY);
postOnAnimation(this::tick);
}
/** The roads and the grid in metres east and south of an anchor at the car. */
private void build(double atX, double atY, double mpu) {
anchorX = atX;
anchorY = atY;
drawnVersion = map.version();
float m = (float) mpu;
for (Path p : roads) p.reset();
float u = unit();
double reach = getWidth() / 2f / (MAP_SCALE * u);
double range = reach / mpu;
for (RoadTile tile : map.tiles()) {
double ox = (double) tile.x * MapGeometry.EXTENT - atX, oy = (double) tile.y * MapGeometry.EXTENT - atY;
if (ox > range || oy > range || ox + MapGeometry.EXTENT < -range || oy + MapGeometry.EXTENT < -range) {
continue;
}
float fx = (float) ox, fy = (float) oy;
for (int i = 0; i < tile.points.length; i++) {
int[] b = tile.bounds[i];
if (fx + b[2] < -range || fx + b[0] > range || fy + b[3] < -range || fy + b[1] > range) continue;
Path p = roads[tile.classes[i]];
int[] pts = tile.points[i];
p.moveTo((fx + pts[0]) * m, (fy + pts[1]) * m);
for (int k = 2; k < pts.length; k += 2) p.lineTo((fx + pts[k]) * m, (fy + pts[k + 1]) * m);
}
}
// Grid lines on whole multiples of GRID_M east and north, so they stay put on the ground.
grid.reset();
float r = (float) reach;
float offE = (float) ((atX * mpu) % GRID_M), offS = (float) ((atY * mpu) % GRID_M);
int lines = (int) (r / GRID_M) + 1;
for (int k = -lines; k <= lines; k++) {
float x = k * GRID_M - offE, y = k * GRID_M - offS;
grid.moveTo(x, -r);
grid.lineTo(x, r);
grid.moveTo(-r, y);
grid.lineTo(r, y);
}
}
@Override
protected void onDraw(Canvas c) {
if (Double.isNaN(anchorX) || drawnScale <= 0f) return;
float u = unit(), px = drawnScale;
c.save();
c.translate(getWidth() / 2f, getHeight() / 2f);
c.scale(px, px);
stroke(c, grid, AccentColor.withAlpha(main, 0x26), 1f * u / px);
stroke(c, roads[3], AccentColor.withAlpha(main, 0xA0), 1.6f * u / px);
stroke(c, roads[2], AccentColor.withAlpha(main, 0xD8), 2.2f * u / px);
glow(c, roads[1], main, 2.6f * u / px);
glow(c, roads[0], light, 3.4f * u / px);
c.restore();
}
private void stroke(Canvas c, Path p, int colour, float width) {
line.setColor(colour);
line.setStrokeWidth(width);
c.drawPath(p, line);
}
/** As GridView's glow: two wide faint strokes under a sharp one. */
private void glow(Canvas c, Path p, int colour, float width) {
stroke(c, p, AccentColor.withAlpha(colour, 0x18), width * 6f);
stroke(c, p, AccentColor.withAlpha(colour, 0x40), width * 3f);
stroke(c, p, colour, width);
}
}
@@ -65,6 +65,8 @@ final class MapRoads {
return t; return t;
}); });
private volatile Fix fix; private volatile Fix fix;
/** Goes up whenever a tile arrives or is dropped, so a view knows to rebuild its shapes. */
private volatile int version;
/** The last bearing while moving; GPS has none at a standstill. */ /** The last bearing while moving; GPS has none at a standstill. */
private volatile float bearing = Float.NaN; private volatile float bearing = Float.NaN;
private volatile boolean serverMissing; private volatile boolean serverMissing;
@@ -100,6 +102,10 @@ final class MapRoads {
return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f; return f == null || SystemClock.elapsedRealtime() - f.elapsedMs > FIX_FRESH_MS ? null : f;
} }
int version() {
return version;
}
Collection<RoadTile> tiles() { Collection<RoadTile> tiles() {
return tiles.values(); return tiles.values();
} }
@@ -116,7 +122,10 @@ final class MapRoads {
/** Queues the 3×3 tiles round (cx, cy), the car's own first, and forgets far ones. */ /** Queues the 3×3 tiles round (cx, cy), the car's own first, and forgets far ones. */
private void want(int cx, int cy) { private void want(int cx, int cy) {
for (Long key : tiles.keySet()) { for (Long key : tiles.keySet()) {
if (Math.abs(MapGeometry.keyX(key) - cx) > 2 || Math.abs(MapGeometry.keyY(key) - cy) > 2) tiles.remove(key); if (Math.abs(MapGeometry.keyX(key) - cx) > 2 || Math.abs(MapGeometry.keyY(key) - cy) > 2) {
tiles.remove(key);
version++;
}
} }
int[][] order = { {0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}, {1, -1}, {1, 1}, {-1, 1}, {-1, -1} }; int[][] order = { {0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}, {1, -1}, {1, 1}, {-1, 1}, {-1, -1} };
long now = SystemClock.elapsedRealtime(); long now = SystemClock.elapsedRealtime();
@@ -152,6 +161,7 @@ final class MapRoads {
} }
if (cached != null) { if (cached != null) {
tiles.put(key, cached); tiles.put(key, cached);
version++;
if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return; if (System.currentTimeMillis() - file.lastModified() < DISK_AGE_MS) return;
} }
ServerConfig config = ServerConfig.get(context); ServerConfig config = ServerConfig.get(context);
@@ -172,6 +182,7 @@ final class MapRoads {
} }
failedAt.remove(key); failedAt.remove(key);
tiles.put(key, tile); tiles.put(key, tile);
version++;
save(file, r.body); save(file, r.body);
} catch (Exception e) { } catch (Exception e) {
Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName()); Log.w(TAG, "Map tile not loaded: " + e.getClass().getSimpleName());
@@ -7,6 +7,8 @@ import android.content.IntentFilter;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import com.evsuite.hardware.AssistBlock;
import com.evsuite.hardware.EVHardware;
import com.evsuite.hardware.probe.VendorSurfaceProbe; import com.evsuite.hardware.probe.VendorSurfaceProbe;
import java.io.File; import java.io.File;
@@ -26,7 +28,11 @@ import java.util.Map;
* The signal recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the * The signal recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the
* head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager property and every * head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager property and every
* value getter of the vendor objects, read only) again and again, and every steering-wheel key * value getter of the vendor objects, read only) again and again, and every steering-wheel key
* the head unit reports, into a {@link SignalRecording}. The driver notes the times they do * the head unit reports, into a {@link SignalRecording}. It also listens, as the head unit's own
* driver-assistance manager does, to the properties of the assistance block
* ({@link AssistBlock}): subscribed for change events the whole time, because a value that is
* only filled while someone listens reads as its default when polled (2.40.3, still the
* cruise control). The driver notes the times they do
* what is to be found; the values that change then are the leads. (Built to find the adaptive * what is to be found; the values that change then are the leads. (Built to find the adaptive
* cruise control, 2.32.0: on SWI68 the head unit gets none of it, see SPECS.md.) * cruise control, 2.32.0: on SWI68 the head unit gets none of it, see SPECS.md.)
* *
@@ -85,6 +91,21 @@ final class SignalRecorder {
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
// No key reports: the properties are recorded all the same. // No key reports: the properties are recorded all the same.
} }
EVHardware.PropertyListening listening = null;
try {
listening = EVHardware.INSTANCE.listenToProperties(AssistBlock.INSTANCE::contains,
(id, area, value) -> {
rec.event(System.currentTimeMillis(),
String.format(Locale.US, "CPM 0x%08X/%d", id, area), value);
return kotlin.Unit.INSTANCE;
});
} catch (Throwable t) {
rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName());
}
rec.note(System.currentTimeMillis(), listening == null
? "listening to the assistance properties: not possible (no car service)"
: "listening to " + listening.getSubscribed() + " assistance properties ("
+ listening.getRefused() + " refused)");
try { try {
long end = startedMs + DURATION_MS; long end = startedMs + DURATION_MS;
while (running && System.currentTimeMillis() < end) { while (running && System.currentTimeMillis() < end) {
@@ -99,6 +120,13 @@ final class SignalRecorder {
} catch (Throwable t) { } catch (Throwable t) {
rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName()); rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName());
} finally { } finally {
if (listening != null) {
try {
listening.close();
} catch (Throwable ignored) {
// the car service is gone
}
}
if (keysRegistered) { if (keysRegistered) {
try { try {
app.unregisterReceiver(keys); app.unregisterReceiver(keys);
@@ -130,7 +158,10 @@ final class SignalRecorder {
text.add("Steering-wheel keys and notes:"); text.add("Steering-wheel keys and notes:");
text.addAll(rec.notes()); text.addAll(rec.notes());
text.add(""); text.add("");
text.add("Changed values, the rarest first:"); text.add("Assistance properties heard (" + rec.events() + " change events), changing ones first:");
text.addAll(rec.heard());
text.add("");
text.add("Changed values (polled), the rarest first:");
text.addAll(rec.summary()); text.addAll(rec.summary());
text.add(""); text.add("");
text.add("Everything, in order:"); text.add("Everything, in order:");
@@ -168,14 +199,17 @@ final class SignalRecorder {
long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs; long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs;
if (running) { if (running) {
out.add(context.getString(R.string.signal_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60, out.add(context.getString(R.string.signal_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60,
recording.captures())); recording.captures(), recording.events()));
} else { } else {
out.add(context.getString(R.string.signal_recorder_done, SignalRecording.time(startedMs), out.add(context.getString(R.string.signal_recorder_done, SignalRecording.time(startedMs),
recording.captures(), result == null ? "…" : result)); recording.captures(), recording.events(), result == null ? "…" : result));
} }
// Steering-wheel keys and the like first: few, and each one a lead. // Steering-wheel keys and the like first: few, and each one a lead.
List<String> notes = recording.notes(); List<String> notes = recording.notes();
out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size())); out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size()));
// Then what the listening heard, changing properties first.
List<String> heard = recording.heard();
for (String line : heard.subList(0, Math.min(12, heard.size()))) out.add("heard " + line);
out.addAll(recording.summary()); out.addAll(recording.summary());
return out; return out;
} }
@@ -16,6 +16,10 @@ import java.util.TreeSet;
* feature on and off, changes a setting). It keeps what changed, when, and per value how * feature on and off, changes a setting). It keeps what changed, when, and per value how
* often and through which values, so the value that follows the action stands out: it * often and through which values, so the value that follows the action stands out: it
* changes a few times, at the moments the driver noted. * changes a few times, at the moments the driver noted.
*
* Besides the captures (polled), it keeps the change events of the properties the recorder
* listens to ({@link #event}): how many per property and through which values. A value the
* head unit only fills while someone listens shows up there and nowhere else.
* Pure Java, unit-tested. * Pure Java, unit-tested.
*/ */
final class SignalRecording { final class SignalRecording {
@@ -31,6 +35,10 @@ final class SignalRecording {
private final List<String> notes = new ArrayList<>(); private final List<String> notes = new ArrayList<>();
private final Map<String, Integer> changes = new HashMap<>(); private final Map<String, Integer> changes = new HashMap<>();
private final Map<String, LinkedHashSet<String>> seen = new HashMap<>(); private final Map<String, LinkedHashSet<String>> seen = new HashMap<>();
private int events;
private final Map<String, String> lastEvent = new HashMap<>();
private final Map<String, Integer> eventCounts = new HashMap<>();
private final Map<String, LinkedHashSet<String>> eventValues = new HashMap<>();
static String time(long ms) { static String time(long ms) {
return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms)); return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms));
@@ -65,6 +73,45 @@ final class SignalRecording {
notes.add(time(ms) + " " + what); notes.add(time(ms) + " " + what);
} }
/**
* A change event from a property listened to. Every one is counted; the timeline gets a
* line only when the value differs from that property's last event.
*/
synchronized void event(long ms, String key, String value) {
events++;
eventCounts.merge(key, 1, Integer::sum);
String before = lastEvent.put(key, value);
if (value.equals(before)) return;
lines.add(time(ms) + " heard " + key + " = " + value);
LinkedHashSet<String> values = eventValues.computeIfAbsent(key, k -> new LinkedHashSet<>());
if (values.size() < VALUES_KEPT) values.add(shorten(value));
}
synchronized int events() {
return events;
}
/**
* Every property that sent events: first those that sent different values, the fewest
* events first (the leads), then those that only ever repeated one value.
*/
synchronized List<String> heard() {
List<String> keys = new ArrayList<>(eventCounts.keySet());
keys.sort((a, b) -> {
boolean va = eventValues.get(a).size() > 1, vb = eventValues.get(b).size() > 1;
if (va != vb) return va ? -1 : 1;
int c = Integer.compare(eventCounts.get(a), eventCounts.get(b));
return c != 0 ? c : a.compareTo(b);
});
List<String> out = new ArrayList<>();
for (String key : keys) {
LinkedHashSet<String> values = eventValues.get(key);
out.add(key + " " + eventCounts.get(key) + " events " + String.join(" → ", values)
+ (values.size() >= VALUES_KEPT ? " …" : ""));
}
return out;
}
/** The notes alone, in order. */ /** The notes alone, in order. */
synchronized List<String> notes() { synchronized List<String> notes() {
return new ArrayList<>(notes); return new ArrayList<>(notes);
+3 -3
View File
@@ -223,11 +223,11 @@
<string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string> <string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string>
<string name="drive_mode_unknown">an unknown mode (%1$s)</string> <string name="drive_mode_unknown">an unknown mode (%1$s)</string>
<string name="label_signal_recorder">Signal recorder</string> <string name="label_signal_recorder">Signal recorder</string>
<string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, plus the steering-wheel keys it reports, and lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string> <string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, listens to the driver-assistance properties for every change they report, and notes the steering-wheel keys; then lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string>
<string name="btn_signal_record">Record 3 minutes</string> <string name="btn_signal_record">Record 3 minutes</string>
<string name="btn_signal_stop">Stop recording</string> <string name="btn_signal_stop">Stop recording</string>
<string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures. Keys reported, then values changed so far, the rarest first:</string> <string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string>
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$s. Keys reported, then values changed, the rarest first:</string> <string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string>
<string name="call_log_empty">No upload attempted yet.</string> <string name="call_log_empty">No upload attempted yet.</string>
<string name="update_log_title">Updates, newest first:</string> <string name="update_log_title">Updates, newest first:</string>
<string name="btn_flush_now">Upload now</string> <string name="btn_flush_now">Upload now</string>
@@ -63,4 +63,22 @@ public class SignalRecordingTest {
assertTrue(r.lines().get(4).endsWith("key 287 down")); assertTrue(r.lines().get(4).endsWith("key 287 down"));
assertEquals(1, r.notes().size()); assertEquals(1, r.notes().size());
} }
@Test
public void heardPropertiesWithChangingValuesComeFirst() {
SignalRecording r = new SignalRecording();
for (int i = 0; i < 5; i++) r.event(i * 1000, "CPM 0x2140A10A/0", "1"); // repeats one value
r.event(1000, "CPM 0x2160A15B/0", "0.0");
r.event(2000, "CPM 0x2160A15B/0", "101.0");
r.event(3000, "CPM 0x2160A15B/0", "101.0");
r.event(4000, "CPM 0x2160A15B/0", "90.0");
List<String> heard = r.heard();
assertEquals(2, heard.size());
assertEquals("CPM 0x2160A15B/0 4 events 0.0 → 101.0 → 90.0", heard.get(0));
assertEquals("CPM 0x2140A10A/0 5 events 1", heard.get(1));
assertEquals(9, r.events());
// The timeline holds only the changes: 0.0, 101.0, 90.0 and the first 1.
long lines = r.lines().stream().filter(l -> l.contains("heard ")).count();
assertEquals(4, lines);
}
} }
@@ -16,10 +16,17 @@ import android.widget.Toast;
* STATUS_PENDING_USER_ACTION is the case worth understanding: the platform is saying it will * STATUS_PENDING_USER_ACTION is the case worth understanding: the platform is saying it will
* install, but wants the driver to confirm. That happens when the app is not granted a silent * install, but wants the driver to confirm. That happens when the app is not granted a silent
* install, and the only correct response is to launch the confirmation activity it hands us. * install, and the only correct response is to launch the confirmation activity it hands us.
*
* Any other failure — on the car the session is refused, "Not allowed to install non-system
* apps on internal storage", and that answer can come here rather than as an exception — opens
* Android's installer screen with the verified APK, as {@link OtaUpdater#install} does for an
* immediate refusal. Only a cancel by the driver ends there.
*/ */
public final class OtaInstallResultReceiver extends BroadcastReceiver { public final class OtaInstallResultReceiver extends BroadcastReceiver {
static final String ACTION_INSTALL_RESULT = "com.stinnesbeck.nilsmg4.OTA_INSTALL_RESULT"; static final String ACTION_INSTALL_RESULT = "com.stinnesbeck.nilsmg4.OTA_INSTALL_RESULT";
static final String EXTRA_APK = "com.stinnesbeck.nilsmg4.extra.APK";
static final String EXTRA_SESSION = "com.stinnesbeck.nilsmg4.extra.SESSION";
private static final String TAG = "EVABRP.Update"; private static final String TAG = "EVABRP.Update";
@@ -31,12 +38,16 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver {
Log.i(TAG, "OTA install result: status=" + status + " msg=" + message); Log.i(TAG, "OTA install result: status=" + status + " msg=" + message);
UpdateLog.record(context, "Install result: status " + status UpdateLog.record(context, "Install result: status " + status
+ (message != null ? ", " + message : "")); + (message != null ? ", " + message : ""));
int session = intent.getIntExtra(EXTRA_SESSION, -1);
if (session >= 0) OtaUpdater.noteAnswer(session);
String apkPath = intent.getStringExtra(EXTRA_APK);
switch (status) { switch (status) {
case PackageInstaller.STATUS_PENDING_USER_ACTION: case PackageInstaller.STATUS_PENDING_USER_ACTION:
Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT); Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT);
if (confirm == null) { if (confirm == null) {
toast(context, context.getString(R.string.ota_install_failed, "no confirm intent")); UpdateLog.record(context, "No confirmation screen offered");
openInstallerScreen(context, apkPath, "no confirm intent");
return; return;
} }
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
@@ -44,23 +55,52 @@ public final class OtaInstallResultReceiver extends BroadcastReceiver {
context.startActivity(confirm); context.startActivity(confirm);
} catch (Throwable t) { } catch (Throwable t) {
Log.e(TAG, "OTA confirm UI failed: " + t.getMessage()); Log.e(TAG, "OTA confirm UI failed: " + t.getMessage());
toast(context, context.getString(R.string.ota_install_failed, UpdateLog.record(context, "Confirmation screen failed: " + t.getClass().getSimpleName());
t.getClass().getSimpleName())); openInstallerScreen(context, apkPath, t.getClass().getSimpleName());
} }
break; break;
case PackageInstaller.STATUS_SUCCESS: case PackageInstaller.STATUS_SUCCESS:
toast(context, context.getString(R.string.ota_install_success)); toast(context, context.getString(R.string.ota_install_success));
break; break;
case PackageInstaller.STATUS_FAILURE_ABORTED:
// The driver cancelled: nothing opens again.
toast(context, context.getString(R.string.ota_install_failed,
message != null ? message : "cancelled"));
break;
default: default:
// The status message is the package manager's own words — INSTALL_FAILED_* // The status message is the package manager's own words — INSTALL_FAILED_*
// and friends. Surfaced verbatim, because guessing at it is what made the // and friends — kept verbatim in the update log, because guessing at it is what
// previous failure unreadable. // made an earlier failure unreadable. Then the installer screen, which the car
toast(context, context.getString(R.string.ota_install_failed, // allows where it refuses the session.
message != null ? message : "status " + status)); openInstallerScreen(context, apkPath, message != null ? message : "status " + status);
break; break;
} }
} }
/**
* Opens Android's installer screen for the APK at [apkPath] off the main thread (it is
* copied to Downloads first); a toast with [why] when there is none or it cannot open.
*/
private void openInstallerScreen(Context context, String apkPath, String why) {
java.io.File apk = apkPath == null ? null : new java.io.File(apkPath);
if (apk == null || !apk.isFile()) {
toast(context, context.getString(R.string.ota_install_failed, why));
return;
}
PendingResult pending = goAsync();
Context app = context.getApplicationContext();
new Thread(() -> {
try {
if (!OtaUpdater.openInstallerScreen(app, apk)) {
new android.os.Handler(android.os.Looper.getMainLooper()).post(() ->
toast(app, app.getString(R.string.ota_install_failed, why)));
}
} finally {
pending.finish();
}
}, "ota-fallback").start();
}
private static void toast(Context context, String text) { private static void toast(Context context, String text) {
Toast.makeText(context.getApplicationContext(), text, Toast.LENGTH_LONG).show(); Toast.makeText(context.getApplicationContext(), text, Toast.LENGTH_LONG).show();
} }
@@ -464,21 +464,57 @@ final class OtaUpdater {
static InstallResult install(Context context, File apk) { static InstallResult install(Context context, File apk) {
if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE; if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE;
try { try {
commitSession(context, apk); int sessionId = commitSession(context, apk);
watchForAnswer(context, sessionId, apk);
return InstallResult.SESSION_STARTED; return InstallResult.SESSION_STARTED;
} catch (Throwable t) { } catch (Throwable t) {
Log.w(TAG, "OTA session install failed; trying system install UI", t); Log.w(TAG, "OTA session install failed; trying system install UI", t);
UpdateLog.record(context, "Installer session failed: " + t); UpdateLog.record(context, "Installer session failed: " + t);
return openInstallerScreen(context, apk) ? InstallResult.INSTALL_UI_STARTED : InstallResult.SESSION_FAILED;
}
}
/** The installer screen for [apk], as the fallback; false if even that could not open. */
static boolean openInstallerScreen(Context context, File apk) {
try { try {
launchInstallerActivity(context, apk); launchInstallerActivity(context, apk);
UpdateLog.record(context, "Opened the system installer instead"); UpdateLog.record(context, "Opened the system installer instead");
return InstallResult.INSTALL_UI_STARTED; return true;
} catch (Throwable fallback) { } catch (Throwable fallback) {
Log.w(TAG, "OTA install UI failed", fallback); Log.w(TAG, "OTA install UI failed", fallback);
UpdateLog.record(context, "System installer failed too: " + fallback); UpdateLog.record(context, "System installer failed too: " + fallback);
return InstallResult.SESSION_FAILED; return false;
} }
} }
/** How long a committed session may stay silent before the installer screen is opened. */
private static final long ANSWER_WAIT_MS = 20_000L;
/** Sessions the platform has answered (any status), so the watch knows to stand down. */
private static final java.util.Set<Integer> answered =
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
/** The platform said something about [sessionId]; called by {@link OtaInstallResultReceiver}. */
static void noteAnswer(int sessionId) {
answered.add(sessionId);
}
/**
* A committed session normally answers within a second or two: installed (the app
* restarts), refused, or "confirm on screen". If it says nothing at all, the update would
* end silently after its download, so the installer screen is opened instead.
*/
private static void watchForAnswer(Context context, int sessionId, File apk) {
Context app = context.getApplicationContext();
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
if (answered.contains(sessionId)) return;
UpdateLog.record(app, "No answer from the installer after " + ANSWER_WAIT_MS / 1000 + " s");
try {
app.getPackageManager().getPackageInstaller().abandonSession(sessionId);
} catch (Throwable ignored) {
// already gone
}
new Thread(() -> openInstallerScreen(app, apk), "ota-fallback").start();
}, ANSWER_WAIT_MS);
} }
/** /**
@@ -510,7 +546,8 @@ final class OtaUpdater {
} }
} }
private static void commitSession(Context context, File apk) throws Exception { /** Commits [apk] to a new installer session and returns its id. */
private static int commitSession(Context context, File apk) throws Exception {
PackageInstaller installer = context.getPackageManager().getPackageInstaller(); PackageInstaller installer = context.getPackageManager().getPackageInstaller();
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams( PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL); PackageInstaller.SessionParams.MODE_FULL_INSTALL);
@@ -533,7 +570,10 @@ final class OtaUpdater {
} }
Intent callback = new Intent(context, OtaInstallResultReceiver.class) Intent callback = new Intent(context, OtaInstallResultReceiver.class)
.setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT) .setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT)
.setPackage(context.getPackageName()); .setPackage(context.getPackageName())
// Where the verified APK is, for the installer screen if the session fails.
.putExtra(OtaInstallResultReceiver.EXTRA_APK, apk.getAbsolutePath())
.putExtra(OtaInstallResultReceiver.EXTRA_SESSION, sessionId);
// MUTABLE: the platform fills in EXTRA_STATUS and, when it wants the driver to // MUTABLE: the platform fills in EXTRA_STATUS and, when it wants the driver to
// confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty. // confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty.
PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback, PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback,
@@ -543,6 +583,7 @@ final class OtaUpdater {
session.commit(pending.getIntentSender()); session.commit(pending.getIntentSender());
committed = true; committed = true;
Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)"); Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
return sessionId;
} finally { } finally {
// Abandon failed staging, but always close our local handle after a commit too. // Abandon failed staging, but always close our local handle after a commit too.
if (!committed) { if (!committed) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 105 KiB