Skip to content

Commit efe942b

Browse files
malysclaude
andcommitted
feat(diagnostics): split-screen probe for the navigation (CP-093)
[split_screen] in diagnostic.txt answers whether the car's map can be embedded beside the companion (map 2/3) or only its guidance can be shown (strip 1/3). Read-only: hidden ActivityView class, platform signature, launcher's ACTIVITY_EMBEDDING grant, map activities flagged allowEmbedded, displays added since process start (the launcher's map card), and the synchronous guidance getters. Ends on a verdict. In main, not unstable: the owner installs stable builds only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ae74540 commit efe942b

5 files changed

Lines changed: 202 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.
66

77
### Added
88

9+
- **Split-screen probe (CP-093).** `diagnostic.txt` gains `[split_screen]`: whether the car's
10+
map could be embedded beside the companion (hidden `ActivityView` present, platform signature,
11+
launcher holding the embedding permission, map activities that allow embedding, displays added
12+
while the launcher's map card is up) or only its guidance answers (map navigating, road,
13+
remaining distance and time, next manoeuvre). Ends on `split_screen_verdict=`. Read-only, no
14+
new permission, in stable builds.
915
- **Read-only evidence probes for the open on-car tickets (CP-055, CP-078–CP-081, CP-083,
1016
CP-086, CP-087, CP-090).** Nothing new is read from or written to the car.
1117
- *Monitor journal (unstable, CP-078/079/080).* A persistent, bounded (200 lines), atomically

‎app/src/main/java/com/evsuite/chargepilot/ChargePilotApp.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class ChargePilotApp : Application() {
1414
EvidenceCaptureHook.startProbes(this)
1515
// CP-080: a small elapsedRealtime here is a fresh boot; a large one, a restarted process.
1616
ValidationProbe.monitor(null) { "process start" }
17+
// CP-093: displays appearing while the vendor launcher shows its map card; read-only.
18+
SplitScreenProbe.watch(this)
1719
// The vehicle voice binds asynchronously; asking now means the first coach line, or the
1820
// settings test, finds it bound. Idempotent, and a car without it just falls back.
1921
if (EcoCoach.voiceEnabled(this)) EcoVoice.connect(this)

‎app/src/main/java/com/evsuite/chargepilot/DiagnosticsActivity.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,11 @@ class DiagnosticsActivity : PrimaryNavigationActivity() {
323323
)
324324
}
325325
appendLine()
326+
// CP-093. Whether the car's map can share the screen with the companion (embedded,
327+
// 2/3) or only its guidance can (strip, 1/3). Read-only; the verdict is the last line.
328+
appendLine("[split_screen]")
329+
SplitScreenProbe.report(this@DiagnosticsActivity).forEach(::appendLine)
330+
appendLine()
326331
appendLine("[property_probe]")
327332
appendLine(getString(R.string.diagnostics_properties))
328333
appendLine(getString(R.string.diagnostics_properties_hint))
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package com.evsuite.chargepilot
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.pm.ActivityInfo
7+
import android.content.pm.PackageManager
8+
import android.content.res.Resources
9+
import android.hardware.display.DisplayManager
10+
import android.view.Display
11+
import com.evsuite.hardware.FirmwareInfo
12+
import com.evsuite.hardware.saic.SaicNavGuidance
13+
import java.util.ArrayDeque
14+
15+
/**
16+
* CP-093. Can the car's map share one screen with the companion, and if not, can its guidance?
17+
*
18+
* Read-only on every line: nothing is embedded, started or sent. It sits in `main` rather than
19+
* the unstable probes because the owner installs stable builds only, and a probe that never
20+
* reaches the car answers nothing.
21+
*
22+
* **Why displays are watched from process start.** An embedded activity lives on a virtual
23+
* display its host creates while the embed is on screen. The vendor launcher's map card is up
24+
* when EVChargePilot is not, so asking at export time would only ever see the panel; the
25+
* listener keeps every display that appeared while the trip monitor held the process alive.
26+
*
27+
* The hidden members are read by reflection and every read is wrapped: a firmware that blocks
28+
* one reports `unreadable` or `false` for that line, never a crash.
29+
*/
30+
@SuppressLint("PrivateApi", "DiscouragedPrivateApi", "SoonBlockedPrivateApi", "BlockedPrivateApi")
31+
object SplitScreenProbe {
32+
33+
enum class Verdict { MAP_EMBEDDABLE, GUIDANCE_ONLY, NEITHER }
34+
35+
/** The three facts the decision rests on, kept apart from Android so a JVM test covers it. */
36+
internal fun verdict(
37+
canEmbed: Boolean,
38+
navAllowsEmbedding: Boolean,
39+
guidanceAnswers: Boolean,
40+
): Verdict = when {
41+
canEmbed && navAllowsEmbedding -> Verdict.MAP_EMBEDDABLE
42+
guidanceAnswers -> Verdict.GUIDANCE_ONLY
43+
else -> Verdict.NEITHER
44+
}
45+
46+
private const val EMBEDDING = "android.permission.ACTIVITY_EMBEDDING"
47+
48+
/** `ActivityInfo.FLAG_ALLOW_EMBEDDED`, hidden on API 28; read by reflection when present. */
49+
private const val FLAG_ALLOW_EMBEDDED_FALLBACK = 0x80000000.toInt()
50+
51+
private const val MAX_SEEN = 16
52+
53+
/** Guarded by itself: written on the main looper, read on the diagnostics worker. */
54+
private val seen = ArrayDeque<String>()
55+
56+
@Volatile
57+
private var watching = false
58+
59+
/** Called once from the application. Registration only; each callback is one line. */
60+
fun watch(context: Context) {
61+
if (watching) return
62+
watching = true
63+
val app = context.applicationContext
64+
SaicNavGuidance.connect(app)
65+
val displays = app.getSystemService(DisplayManager::class.java) ?: return
66+
runCatching {
67+
displays.registerDisplayListener(object : DisplayManager.DisplayListener {
68+
override fun onDisplayAdded(displayId: Int) {
69+
val line = displays.getDisplay(displayId)?.let(::describe) ?: "id=$displayId gone"
70+
synchronized(seen) {
71+
if (seen.size >= MAX_SEEN) seen.removeFirst()
72+
seen.addLast("${System.currentTimeMillis()} $line")
73+
}
74+
}
75+
76+
override fun onDisplayRemoved(displayId: Int) = Unit
77+
override fun onDisplayChanged(displayId: Int) = Unit
78+
}, null)
79+
}
80+
}
81+
82+
/** The `[split_screen]` section. Binder reads: call off the main thread. */
83+
fun report(context: Context): List<String> = runCatching {
84+
val pm = context.packageManager
85+
val lines = mutableListOf<String>()
86+
87+
val activityView = runCatching { Class.forName("android.app.ActivityView") }.isSuccess
88+
val platformSigned =
89+
pm.checkSignatures("android", context.packageName) == PackageManager.SIGNATURE_MATCH
90+
lines += "firmware=${FirmwareInfo.getGeneration().name}"
91+
lines += "activity_view_class=$activityView"
92+
lines += "platform_signed=$platformSigned"
93+
// Not declared on purpose: the probe must not widen the manifest. A platform-signed build
94+
// would be granted it the day it is declared.
95+
lines += "embedding_permission_declared=false"
96+
lines += "multi_window_config=" + listOf(
97+
"config_supportsMultiWindow",
98+
"config_supportsSplitScreenMultiWindow",
99+
"config_supportsFreeformWindowManagement",
100+
).joinToString(";") { "$it:${frameworkBool(it)}" }
101+
102+
pm.queryIntentActivities(
103+
Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME), 0,
104+
).map { it.activityInfo.packageName }.distinct().forEach { home ->
105+
lines += "home=$home activity_embedding=${
106+
pm.checkPermission(EMBEDDING, home) == PackageManager.PERMISSION_GRANTED
107+
}"
108+
}
109+
110+
val nav = MapApps.installedPackage(context)
111+
lines += "nav_package=${nav ?: "none"}"
112+
val embeddable = nav?.let { navActivities(pm, it, lines) }.orEmpty()
113+
lines += "nav_embeddable=${embeddable.ifEmpty { listOf("none") }.joinToString(",")}"
114+
115+
context.getSystemService(DisplayManager::class.java)?.displays.orEmpty()
116+
.forEach { lines += "display_now=${describe(it)}" }
117+
synchronized(seen) { seen.toList() }.ifEmpty { listOf("none since process start") }
118+
.forEach { lines += "display_seen=$it" }
119+
120+
val navigating = SaicNavGuidance.isMapNavigating()
121+
val guidance = SaicNavGuidance.readNow()
122+
lines += "guidance_bound=${SaicNavGuidance.isAvailable} map_navigating=$navigating"
123+
lines += "guidance_now status=${guidance.guideStatus} remaining_raw=${guidance.remainingDistanceRaw} " +
124+
"minutes=${guidance.remainingMinutes} road_present=${guidance.road != null}"
125+
lines += "guidance_listener=${SaicNavGuidance.isListening} events=${guidance.events} " +
126+
"next_turn_raw=${guidance.nextTurnDistanceRaw} icon=${guidance.nextTurnIcon} " +
127+
"direction_present=${guidance.direction != null}"
128+
129+
val guidanceAnswers = guidance.remainingDistanceRaw != null || guidance.road != null
130+
lines += "split_screen_verdict=" +
131+
verdict(activityView && platformSigned, embeddable.isNotEmpty(), guidanceAnswers).name
132+
lines
133+
}.getOrElse { listOf("unavailable: ${it.javaClass.simpleName}") }
134+
135+
/** Lists the map's activities that allow embedding, and records its size and main resize mode. */
136+
private fun navActivities(pm: PackageManager, pkg: String, lines: MutableList<String>): List<String> {
137+
val activities = runCatching {
138+
pm.getPackageInfo(pkg, PackageManager.GET_ACTIVITIES).activities
139+
}.getOrNull().orEmpty()
140+
lines += "nav_activities=${activities.size}"
141+
val flag = runCatching {
142+
ActivityInfo::class.java.getField("FLAG_ALLOW_EMBEDDED").getInt(null)
143+
}.getOrDefault(FLAG_ALLOW_EMBEDDED_FALLBACK)
144+
val launch = pm.getLaunchIntentForPackage(pkg)?.component?.className
145+
activities.firstOrNull { it.name == launch }?.let {
146+
lines += "nav_main=${it.name.removePrefix(pkg)} resize_mode=${resizeMode(it)} exported=${it.exported}"
147+
}
148+
return activities.filter { (it.flags and flag) != 0 }.map { it.name.removePrefix(pkg) }
149+
}
150+
151+
private fun resizeMode(info: ActivityInfo): String = runCatching {
152+
ActivityInfo::class.java.getField("resizeMode").getInt(info).toString()
153+
}.getOrDefault("unreadable")
154+
155+
private fun frameworkBool(name: String): String = runCatching {
156+
val system = Resources.getSystem()
157+
val id = system.getIdentifier(name, "bool", "android")
158+
if (id == 0) "absent" else system.getBoolean(id).toString()
159+
}.getOrDefault("unreadable")
160+
161+
/** Id, name, size, flags and — hidden on API 28 — the package that owns a virtual display. */
162+
private fun describe(display: Display): String {
163+
val owner = runCatching {
164+
Display::class.java.getMethod("getOwnerPackageName").invoke(display) as String?
165+
}.getOrNull()
166+
return "id=${display.displayId} name=${display.name} " +
167+
"size=${display.mode.physicalWidth}x${display.mode.physicalHeight} " +
168+
"flags=0x${Integer.toHexString(display.flags)} owner=${owner ?: "-"}"
169+
}
170+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.evsuite.chargepilot
2+
3+
import com.evsuite.chargepilot.SplitScreenProbe.Verdict
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Test
6+
7+
class SplitScreenProbeTest {
8+
9+
@Test fun `the map wins only when both the host and the map allow embedding`() {
10+
assertEquals(Verdict.MAP_EMBEDDABLE, SplitScreenProbe.verdict(true, true, false))
11+
assertEquals(Verdict.GUIDANCE_ONLY, SplitScreenProbe.verdict(true, false, true))
12+
assertEquals(Verdict.GUIDANCE_ONLY, SplitScreenProbe.verdict(false, true, true))
13+
}
14+
15+
@Test fun `nothing usable is said plainly`() {
16+
assertEquals(Verdict.NEITHER, SplitScreenProbe.verdict(false, true, false))
17+
assertEquals(Verdict.NEITHER, SplitScreenProbe.verdict(true, false, false))
18+
}
19+
}

0 commit comments

Comments
 (0)