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:
2026-10-27 09:19:33 +01:00
co-authored by Claude Opus 5.5
parent 097467f4e0
commit 60ab3118a1
5 changed files with 178 additions and 19 deletions
@@ -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<Int>()
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
}
@@ -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)
}
}