From 60ab3118a14c3a31568b32d256dac309c608642c Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Tue, 27 Oct 2026 09:19:33 +0100 Subject: [PATCH] App 2.40.4: the recorder's listening works on Android 9 listenToProperties looked only for registerCallback (Android 10); the car's car library has registerListener/unregisterListener. Take either, unsubscribe property by property, and say why when it fails. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- .../java/com/evsuite/hardware/EVHardware.kt | 71 +++++++++--- .../evsuite/hardware/PropertyListeningTest.kt | 105 ++++++++++++++++++ SPECS.md | 14 +++ app/build.gradle | 4 +- .../java/com/evsuite/abrp/SignalRecorder.java | 3 +- 5 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 EVHardware/lib/src/test/java/com/evsuite/hardware/PropertyListeningTest.kt diff --git a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt index 5306fda..de60650 100644 --- a/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt +++ b/EVHardware/lib/src/main/java/com/evsuite/hardware/EVHardware.kt @@ -324,6 +324,10 @@ object EVHardware { // Listening to properties (the app's signal recorder) — read only // ------------------------------------------------------------------------- + /** Why the last [listenToProperties] returned null, in a few words; null after a success. */ + @Volatile var listenProblem: String? = null + private set + /** A subscription made by [listenToProperties]; [close] ends it. */ class PropertyListening internal constructor( /** Properties subscribed to, and those the car service refused. */ @@ -342,19 +346,44 @@ object EVHardware { * 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. + * CarPropertyManager or no way to subscribe, with the reason in [listenProblem]; + * [PropertyListening.close] unsubscribes. + * + * Android 9's car library (the MG4's) names the calls `registerListener(CarPropertyEventListener, + * int, float)` and `unregisterListener`; Android 10 renamed them `registerCallback` and + * `unregisterCallback`. Both are looked for. Unsubscribing goes property by property: the + * one-argument `unregisterListener` of Android 9 removes entries from the list it walks and so + * leaves every other property subscribed. */ fun listenToProperties(admit: (Int) -> Boolean, onEvent: (Int, Int, String) -> Unit): PropertyListening? { - val cpm = sCarPropertyManager ?: return null + listenProblem = null + val cpm = sCarPropertyManager ?: run { + listenProblem = "no car service" + 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 methods = cpm.javaClass.methods + val register = methods.firstOrNull { + (it.name == "registerListener" || it.name == "registerCallback") && it.parameterCount == 3 && + it.parameterTypes[1] == Int::class.javaPrimitiveType && + it.parameterTypes[2] == Float::class.javaPrimitiveType + } ?: run { + listenProblem = "no way to subscribe" + return null } val callbackType = register.parameterTypes[0] - if (!callbackType.isInterface) return null + val unregister = methods.firstOrNull { + it.name == register.name.replaceFirst("register", "unregister") && it.parameterCount == 2 && + it.parameterTypes[0] == callbackType && it.parameterTypes[1] == Int::class.javaPrimitiveType + } + val unregisterAll = methods.firstOrNull { + it.name == register.name.replaceFirst("register", "unregister") && it.parameterCount == 1 && + it.parameterTypes[0] == callbackType + } + if (!callbackType.isInterface) { + listenProblem = "unexpected listener type ${callbackType.simpleName}" + return null + } val proxy = Proxy.newProxyInstance(callbackType.classLoader, arrayOf(callbackType)) { self, method, args -> when (method.name) { "onChangeEvent" -> { @@ -385,7 +414,7 @@ object EVHardware { } } val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null - var subscribed = 0 + val subscribedIds = ArrayList() var refused = 0 for (config in configs) { if (config == null) continue @@ -399,21 +428,31 @@ object EVHardware { 1f.coerceIn(min, maxOf(min, max)) } try { - if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribed++ + if (register.invoke(cpm, proxy, id, rate) == false) refused++ else subscribedIds.add(id) } 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()}") + AppLogger.i(TAG, "listening to ${subscribedIds.size} properties ($refused refused) via ${register.name}") + PropertyListening(subscribedIds.size, refused) { + for (id in subscribedIds) { + try { + if (unregister != null) unregister.invoke(cpm, proxy, id) + } catch (e: Exception) { + AppLogger.d(TAG, "listen: unsubscribe 0x%08X failed: %s".format(id, e.readFailureReason())) + } + } + if (unregister == null) { + try { + unregisterAll?.invoke(cpm, proxy) + } catch (e: Exception) { + AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}") + } } } } catch (e: Exception) { + listenProblem = e.readFailureReason() AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}") null } diff --git a/EVHardware/lib/src/test/java/com/evsuite/hardware/PropertyListeningTest.kt b/EVHardware/lib/src/test/java/com/evsuite/hardware/PropertyListeningTest.kt new file mode 100644 index 0000000..6205401 --- /dev/null +++ b/EVHardware/lib/src/test/java/com/evsuite/hardware/PropertyListeningTest.kt @@ -0,0 +1,105 @@ +package com.evsuite.hardware + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class PropertyListeningTest { + + class Config(val propertyId: Int, val changeMode: Int, val minSampleRate: Float, val maxSampleRate: Float) + class Value(val propertyId: Int, val areaId: Int, val value: Any) + + /** Shaped like Android 9's CarPropertyManager, the car's: registerListener, and an + * unregisterListener(listener) that skips every other property as the car's does. */ + class Android9Manager(private val configs: List) { + interface CarPropertyEventListener { + fun onChangeEvent(value: Any?) + fun onErrorEvent(propertyId: Int, areaId: Int) + } + + val active = LinkedHashMap>() + val rates = HashMap() + + fun getPropertyList(): List = configs + + fun registerListener(listener: CarPropertyEventListener, propertyId: Int, rate: Float): Boolean { + active.getOrPut(propertyId) { ArrayList() }.add(listener) + rates[propertyId] = rate + return true + } + + fun unregisterListener(listener: CarPropertyEventListener) { + val ids = active.keys.toMutableList() + var i = 0 + while (i < ids.size) { + unregisterListener(listener, ids[i]) + if (active[ids[i]] == null) ids.removeAt(i) // as SparseArray.remove under keyAt(i) + i++ + } + } + + fun unregisterListener(listener: CarPropertyEventListener, propertyId: Int) { + val list = active[propertyId] ?: return + list.remove(listener) + if (list.isEmpty()) active.remove(propertyId) + } + + fun fire(propertyId: Int, value: Any) { + active[propertyId]?.toList()?.forEach { it.onChangeEvent(Value(propertyId, 0x01000000, value)) } + } + } + + class NoListeners { + fun getPropertyList(): List = emptyList() + } + + private val field = EVHardware::class.java.getDeclaredField("sCarPropertyManager").apply { isAccessible = true } + + @After + fun reset() = field.set(null, null) + + @Test + fun `subscribes through Android 9's registerListener and unsubscribes every property`() { + val manager = Android9Manager(listOf( + Config(0x2160A15B, 1, 0f, 0f), // in the block, on change + Config(0x2140A15A, 1, 0f, 0f), + Config(0x2160A1A3, 2, 0f, 10f), // continuous + Config(0x2140A101, 1, 0f, 0f), + Config(0x11400400, 1, 0f, 0f), // outside the block + )) + field.set(null, manager) + val heard = ArrayList() + + val listening = EVHardware.listenToProperties(AssistBlock::contains) { id, area, value -> + heard += "%08X/%d=%s".format(id, area, value) + } + + assertNotNull(EVHardware.listenProblem, listening) + assertEquals(4, listening!!.subscribed) + assertEquals(0, listening.refused) + assertEquals(setOf(0x2160A15B, 0x2140A15A, 0x2160A1A3, 0x2140A101), manager.active.keys) + assertEquals(0f, manager.rates[0x2160A15B]) + assertEquals(1f, manager.rates[0x2160A1A3]) + + manager.fire(0x2160A15B, 90f) + assertEquals(1, heard.size) + assertTrue(heard[0], heard[0].startsWith("2160A15B/16777216=")) + + listening.close() + assertTrue(manager.active.toString(), manager.active.isEmpty()) + } + + @Test + fun `says why when there is no way to subscribe`() { + field.set(null, NoListeners()) + assertNull(EVHardware.listenToProperties(AssistBlock::contains) { _, _, _ -> }) + assertEquals("no way to subscribe", EVHardware.listenProblem) + + field.set(null, null) + assertNull(EVHardware.listenToProperties(AssistBlock::contains) { _, _, _ -> }) + assertEquals("no car service", EVHardware.listenProblem) + } +} diff --git a/SPECS.md b/SPECS.md index dc44d6f..9007916 100644 --- a/SPECS.md +++ b/SPECS.md @@ -140,6 +140,20 @@ 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 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). +- 2026-10-27 — App 2.40.4: **the recorder's listening works on Android 9**. Both recordings + from the car (08:49 and 08:52, 2.40.3) said "listening to the assistance properties: not + possible": `listenToProperties` looked only for `registerCallback`, Android 10's name; the car's + car library (Android 9) has `registerListener(CarPropertyEventListener, int, float)` and + `unregisterListener`, as the app's `CarPropertyAdapter` already knew. It now takes either, and + unsubscribes property by property (Android 9's one-argument `unregisterListener` removes + entries from the list it walks and would leave every other property subscribed). A failure now + says why (`EVHardware.listenProblem`: no car service, no way to subscribe, or the exception). + Tested with a stand-in manager of Android 9's shape (`PropertyListeningTest`). Correction to + 2.40.3: the ignition listener it was compared with also looks only for `registerCallback`, so + it most likely never subscribes on the car either (it reads the state once at start); left as + it is. What the polled part of the two recordings shows of the block: `0x2160A15B` (by name + the target speed) 0.0 throughout; `0x2140A15A` between 3 and 7 every few captures; + `0x2160A1A3`/`0x2160A1A4` counting up by 0.1; speeds 8–33 km/h. Not tried on the car. - 2026-10-27 — App 2.40.3 published (`car-2.40.3`; also carries 2.40.1 and 2.40.2). - 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. diff --git a/app/build.gradle b/app/build.gradle index 98ff111..8880599 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 148 - versionName "2.40.3" + versionCode 149 + versionName "2.40.4" // 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. diff --git a/app/src/main/java/com/evsuite/abrp/SignalRecorder.java b/app/src/main/java/com/evsuite/abrp/SignalRecorder.java index 4b15276..c5d5eda 100644 --- a/app/src/main/java/com/evsuite/abrp/SignalRecorder.java +++ b/app/src/main/java/com/evsuite/abrp/SignalRecorder.java @@ -103,7 +103,8 @@ final class SignalRecorder { 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 the assistance properties: not possible (" + + EVHardware.INSTANCE.getListenProblem() + ")" : "listening to " + listening.getSubscribed() + " assistance properties (" + listening.getRefused() + " refused)"); try {