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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
@@ -324,6 +324,10 @@ object EVHardware {
|
|||||||
// Listening to properties (the app's signal recorder) — read only
|
// 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. */
|
/** A subscription made by [listenToProperties]; [close] ends it. */
|
||||||
class PropertyListening internal constructor(
|
class PropertyListening internal constructor(
|
||||||
/** Properties subscribed to, and those the car service refused. */
|
/** 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
|
* 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
|
* 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
|
* 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? {
|
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 {
|
return try {
|
||||||
val register = cpm.javaClass.methods.firstOrNull {
|
val methods = cpm.javaClass.methods
|
||||||
it.name == "registerCallback" && it.parameterCount == 3
|
val register = methods.firstOrNull {
|
||||||
} ?: return null
|
(it.name == "registerListener" || it.name == "registerCallback") && it.parameterCount == 3 &&
|
||||||
val unregister = cpm.javaClass.methods.firstOrNull {
|
it.parameterTypes[1] == Int::class.javaPrimitiveType &&
|
||||||
it.name == "unregisterCallback" && it.parameterCount == 1
|
it.parameterTypes[2] == Float::class.javaPrimitiveType
|
||||||
|
} ?: run {
|
||||||
|
listenProblem = "no way to subscribe"
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
val callbackType = register.parameterTypes[0]
|
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 ->
|
val proxy = Proxy.newProxyInstance(callbackType.classLoader, arrayOf(callbackType)) { self, method, args ->
|
||||||
when (method.name) {
|
when (method.name) {
|
||||||
"onChangeEvent" -> {
|
"onChangeEvent" -> {
|
||||||
@@ -385,7 +414,7 @@ object EVHardware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null
|
val configs = cpm.javaClass.getMethod("getPropertyList").invoke(cpm) as? List<*> ?: return null
|
||||||
var subscribed = 0
|
val subscribedIds = ArrayList<Int>()
|
||||||
var refused = 0
|
var refused = 0
|
||||||
for (config in configs) {
|
for (config in configs) {
|
||||||
if (config == null) continue
|
if (config == null) continue
|
||||||
@@ -399,21 +428,31 @@ object EVHardware {
|
|||||||
1f.coerceIn(min, maxOf(min, max))
|
1f.coerceIn(min, maxOf(min, max))
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (e: Exception) {
|
||||||
refused++
|
refused++
|
||||||
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
|
AppLogger.d(TAG, "listen: 0x%08X refused: %s".format(id, e.readFailureReason()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AppLogger.i(TAG, "listening to $subscribed properties ($refused refused)")
|
AppLogger.i(TAG, "listening to ${subscribedIds.size} properties ($refused refused) via ${register.name}")
|
||||||
PropertyListening(subscribed, refused) {
|
PropertyListening(subscribedIds.size, refused) {
|
||||||
try {
|
for (id in subscribedIds) {
|
||||||
unregister?.invoke(cpm, proxy)
|
try {
|
||||||
} catch (e: Exception) {
|
if (unregister != null) unregister.invoke(cpm, proxy, id)
|
||||||
AppLogger.d(TAG, "listen: unsubscribe failed: ${e.readFailureReason()}")
|
} 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) {
|
} catch (e: Exception) {
|
||||||
|
listenProblem = e.readFailureReason()
|
||||||
AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}")
|
AppLogger.w(TAG, "listenToProperties: ${e.readFailureReason()}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Config>) {
|
||||||
|
interface CarPropertyEventListener {
|
||||||
|
fun onChangeEvent(value: Any?)
|
||||||
|
fun onErrorEvent(propertyId: Int, areaId: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
val active = LinkedHashMap<Int, MutableList<CarPropertyEventListener>>()
|
||||||
|
val rates = HashMap<Int, Float>()
|
||||||
|
|
||||||
|
fun getPropertyList(): List<Config> = 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<Config> = 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<String>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
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.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 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
|
- 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.
|
can do about the readout of the cruise control set point"). The 2.32.0 recording only polled.
|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 148
|
versionCode 149
|
||||||
versionName "2.40.3"
|
versionName "2.40.4"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ final class SignalRecorder {
|
|||||||
rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName());
|
rec.note(System.currentTimeMillis(), "listening failed: " + t.getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
rec.note(System.currentTimeMillis(), listening == null
|
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 to " + listening.getSubscribed() + " assistance properties ("
|
||||||
+ listening.getRefused() + " refused)");
|
+ listening.getRefused() + " refused)");
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user