|
| 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 | +} |
0 commit comments