Skip to content

Commit dbe380b

Browse files
malysclaude
andcommitted
feat(probe): map the services the vendor packages declare
The hub maps can only name what someone already guessed to ask for; RI-001, 003, 004, 007 and 008 each start by not knowing which component publishes their signal. componentMap(), added to survey(), lists every com.saicmotor.* (and com.saicvehicleservice.*) package and, from getPackageInfo(GET_SERVICES), each service's class name, exported flag and permission as raw strings. Manifest metadata only: nothing is bound, called or loaded. Capped at 64 packages and 40 services per package, with the cut recorded. The PackageManager is kept from connect(), so callers are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6bd9131 commit dbe380b

3 files changed

Lines changed: 119 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,19 @@ All notable changes to this project are documented here. Format follows
1111
- Probe-only vendor charge readings for the telemetry evidence: `BMS_CHRG_STS`, the AC and DC
1212
plug states and the kWh-since-plug-in counter, ids from MG4Control's SWI133 readings and
1313
unproven on SWI68. Recorded as `candidate.vendor*`; nothing reads them as values yet.
14+
- `RuntimeInterfaceProbe.componentMap()`, part of `survey()`: every service the
15+
`com.saicmotor.*` packages declare, with its exported flag and permission, from
16+
PackageManager metadata only. Nothing is bound or called; capped at 64 packages and
17+
40 services per package, with the cut recorded.
18+
- `windows.widestPercent` in the telemetry evidence, from `EnergySnapshot.widestWindowPercent`.
19+
- `SocConsumptionFitter.describe()` ends with the solve's outcome once both gates pass:
20+
`fit=ready rmse=…` or `fit=refused(<guard>)`. The existing prefix is unchanged.
1421

1522
### Fixed
1623

24+
- **`SaicCharging.chargeLimitPercent()` answers null outside 40–100 %.** SWI68 answered `5`,
25+
a code rather than a percent; callers now take their "limit unreadable" path. The setter
26+
is unchanged.
1727
- **The battery ledger ignores readings taken on the factory clock.** A head unit woken by a
1828
night charge runs on its late-2018 clock until it syncs; one such entry sorted first in the
1929
ledger and split the charge. `BatteryLedgerRecorder` now drops samples before 2025-01-01.

‎lib/src/main/java/com/evsuite/hardware/probe/RuntimeInterfaceProbe.kt‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.evsuite.hardware.probe
22

33
import android.content.Context
4+
import android.content.pm.PackageManager
45
import android.os.IBinder
56
import com.evsuite.hardware.EVHardware
67
import com.evsuite.hardware.FirmwareInfo
@@ -61,6 +62,7 @@ object RuntimeInterfaceProbe {
6162
* it from a capture that has been up for a while.
6263
*/
6364
fun connect(context: Context) {
65+
packages = context.applicationContext.packageManager
6466
SaicHub.connect(context)
6567
engMode.connect(context)
6668
}
@@ -276,6 +278,80 @@ object RuntimeInterfaceProbe {
276278
add(vehicleProperty())
277279
add(vehicleCharging())
278280
add(carDiagnostics())
281+
addAll(componentMap())
282+
}
283+
284+
/** Kept from [connect], so [componentMap] needs no extra argument from the callers. */
285+
@Volatile private var packages: PackageManager? = null
286+
287+
/**
288+
* Every service the vendor packages declare in their manifests, as PackageManager lists it.
289+
*
290+
* The hub maps above can only name what someone already guessed to ask for. This is the
291+
* other direction: what is installed, which services each package declares, and whether an
292+
* unprivileged caller may reach them — RI-001, RI-003, RI-004, RI-007 and RI-008 each start
293+
* by not knowing which component publishes their signal.
294+
*
295+
* Manifest metadata only. Nothing is bound, no transaction is sent and no vendor class is
296+
* loaded, so this cannot reach a write on any interface. Visibility of other packages holds
297+
* because the target is API 28; a later target needs `QUERY_ALL_PACKAGES`, and without it
298+
* this would read as a head unit with no vendor packages at all.
299+
*/
300+
fun componentMap(): List<InterfaceProbe> {
301+
val pm = packages ?: return listOf(
302+
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.ABSENT, note = NOT_CONNECTED),
303+
)
304+
val names = try {
305+
pm.getInstalledPackages(0).map { it.packageName }
306+
.filter { name -> COMPONENT_PREFIXES.any { name.startsWith(it) } }.sorted()
307+
} catch (e: RuntimeException) {
308+
return listOf(
309+
InterfaceProbe(COMPONENT_MAP, null, BindVerdict.DENIED, note = e.javaClass.name),
310+
)
311+
}
312+
return listOf(componentSummary(names)) + names.take(MAX_COMPONENT_PACKAGES).map { name ->
313+
val services = try {
314+
pm.getPackageInfo(name, PackageManager.GET_SERVICES).services.orEmpty()
315+
.map { ServiceRow(it.name, it.exported, it.permission) }
316+
} catch (e: PackageManager.NameNotFoundException) {
317+
return@map InterfaceProbe("$COMPONENT_MAP:$name", null, BindVerdict.ABSENT)
318+
} catch (e: RuntimeException) {
319+
return@map InterfaceProbe(
320+
"$COMPONENT_MAP:$name", null, BindVerdict.DENIED, note = e.javaClass.name,
321+
)
322+
}
323+
componentEntry(name, services)
324+
}
325+
}
326+
327+
/** One declared service, as the manifest states it. */
328+
internal data class ServiceRow(val name: String, val exported: Boolean, val permission: String?)
329+
330+
/** How many vendor packages there are, and how many of them the entries below list. */
331+
internal fun componentSummary(names: List<String>): InterfaceProbe = InterfaceProbe(
332+
COMPONENT_MAP, null, BindVerdict.ANSWERED,
333+
mapOf(
334+
"packages" to names.size.toString(),
335+
"listed" to minOf(names.size, MAX_COMPONENT_PACKAGES).toString(),
336+
),
337+
COMPONENT_NOTE,
338+
)
339+
340+
/**
341+
* One package's services, keyed by class name, the value kept as the manifest's own words.
342+
*
343+
* A package that declares none is still ANSWERED: the manifest was read and said so.
344+
*/
345+
internal fun componentEntry(pkg: String, services: List<ServiceRow>): InterfaceProbe {
346+
val values = LinkedHashMap<String, String?>()
347+
values["services"] = services.size.toString()
348+
services.take(MAX_COMPONENT_SERVICES).forEach {
349+
values[it.name] = "exported=${it.exported} permission=${it.permission}"
350+
}
351+
if (services.size > MAX_COMPONENT_SERVICES) {
352+
values["truncated"] = (services.size - MAX_COMPONENT_SERVICES).toString()
353+
}
354+
return InterfaceProbe("$COMPONENT_MAP:$pkg", null, BindVerdict.ANSWERED, values)
279355
}
280356

281357
/**
@@ -365,6 +441,18 @@ object RuntimeInterfaceProbe {
365441
*/
366442
private val CODE_SWEEP = (0x0..0x12).toList()
367443

444+
private const val COMPONENT_MAP = "componentMap"
445+
private val COMPONENT_PREFIXES = listOf("com.saicmotor.", "com.saicvehicleservice.")
446+
447+
/** A bundle is pasted and read; a few hundred lines of manifest is the useful end of it. */
448+
internal const val MAX_COMPONENT_PACKAGES = 64
449+
internal const val MAX_COMPONENT_SERVICES = 40
450+
451+
private const val NOT_CONNECTED = "connect() was not called, so there is no PackageManager"
452+
private const val COMPONENT_NOTE =
453+
"PackageManager GET_SERVICES only: nothing bound, called or loaded. exported=false or " +
454+
"a permission we do not hold means unreachable from EVSuite, not absent."
455+
368456
/**
369457
* What ABSENT means when there is no adapter client to ask. Carried on every swept entry
370458
* rather than stated once at the top of the bundle: a later session reads one entry, not the

‎lib/src/test/java/com/evsuite/hardware/probe/RuntimeInterfaceProbeTest.kt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,25 @@ class RuntimeInterfaceProbeTest {
9191
val calls = RuntimeInterfaceProbe.CONFIG_CALLS
9292
assertEquals(calls.size, calls.values.map { it.code }.toSet().size)
9393
}
94+
95+
@Test
96+
fun `the component map keeps the manifest's words and says when it stopped listing`() {
97+
val rows = (0 until RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 3).map {
98+
RuntimeInterfaceProbe.ServiceRow("com.saicmotor.x.Service$it", it == 0, null)
99+
}
100+
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.x", rows)
101+
assertEquals("componentMap:com.saicmotor.x", entry.name)
102+
assertEquals(BindVerdict.ANSWERED, entry.verdict)
103+
assertEquals((rows.size).toString(), entry.values["services"])
104+
assertEquals("exported=true permission=null", entry.values["com.saicmotor.x.Service0"])
105+
assertEquals("3", entry.values["truncated"])
106+
assertEquals(RuntimeInterfaceProbe.MAX_COMPONENT_SERVICES + 2, entry.values.size)
107+
}
108+
109+
@Test
110+
fun `a package that declares no service was still read`() {
111+
val entry = RuntimeInterfaceProbe.componentEntry("com.saicmotor.y", emptyList())
112+
assertEquals(BindVerdict.ANSWERED, entry.verdict)
113+
assertEquals(mapOf("services" to "0"), entry.values)
114+
}
94115
}

0 commit comments

Comments
 (0)