Add local companion playback diagnostics for failed background audio
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 56
|
||||
versionName = "0.5.36"
|
||||
versionCode = 57
|
||||
versionName = "0.5.37"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -74,6 +74,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.screens.restartCompanionApp
|
||||
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
@@ -252,6 +253,7 @@ private fun MenuPanel(
|
||||
HubPage.FIPS -> "FIPS Mesh"
|
||||
HubPage.BACKUP -> "Backup & Restore"
|
||||
HubPage.SIGNER -> "Remote Signer"
|
||||
HubPage.AUDIO -> "Playback diagnostics"
|
||||
HubPage.HUB -> "Menu"
|
||||
},
|
||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||
@@ -307,6 +309,7 @@ private fun MenuPanel(
|
||||
onDismiss()
|
||||
restartCompanionApp(hubContext)
|
||||
}
|
||||
HubCard(Icons.Default.Dashboard, "Playback diagnostics", "Local background audio status") { page = HubPage.AUDIO }
|
||||
val versionLabel = remember {
|
||||
runCatching {
|
||||
hubContext.packageManager
|
||||
@@ -451,6 +454,17 @@ private fun MenuPanel(
|
||||
}
|
||||
}
|
||||
|
||||
HubPage.AUDIO -> {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var report by remember { mutableStateOf(CompanionAudioDiagnostics.report(context)) }
|
||||
var copied by remember { mutableStateOf(false) }
|
||||
Text("Local status only. No track names, addresses, credentials, or automatic uploads.", color = TextMuted, fontSize = 12.sp)
|
||||
Text(report, color = TextPrimary, fontSize = 12.sp)
|
||||
MenuItem(label = "Refresh", onClick = { report = CompanionAudioDiagnostics.report(context); copied = false })
|
||||
MenuItem(label = if (copied) "Copied" else "Copy report", onClick = { clipboard.setText(AnnotatedString(report)); copied = true })
|
||||
}
|
||||
|
||||
HubPage.FIPS -> {
|
||||
FipsSection(embedded = true)
|
||||
}
|
||||
@@ -470,7 +484,7 @@ private fun MenuPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER }
|
||||
private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER, AUDIO }
|
||||
|
||||
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||
@Composable
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.webkit.WebMessageCompat
|
||||
import androidx.webkit.WebViewCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import org.json.JSONObject
|
||||
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
|
||||
|
||||
/** Metadata/control only: the authorized WebView owns the stream and queue. */
|
||||
internal data class CompanionAudioState(
|
||||
@@ -50,12 +51,12 @@ internal object CompanionAudioBridge {
|
||||
private set
|
||||
fun retains(web: WebView?) = web != null && view === web && state != null
|
||||
fun attach(web: WebView, urls: List<String>) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { CompanionAudioDiagnostics.record(Event.BRIDGE_UNSUPPORTED); return }
|
||||
val origins = urls.mapNotNull(::cloudVideoOrigin).toSet()
|
||||
if (origins.isEmpty()) return
|
||||
if (origins.isEmpty()) { CompanionAudioDiagnostics.record(Event.NO_APPROVED_ORIGIN); return }
|
||||
val existing = bindings[web]
|
||||
if (existing != null) {
|
||||
if (existing != origins) { bindings[web] = emptySet(); release(web) }
|
||||
if (existing != origins) { CompanionAudioDiagnostics.record(Event.ORIGIN_CHANGED); bindings[web] = emptySet(); release(web) }
|
||||
return
|
||||
}
|
||||
bindings[web] = origins
|
||||
@@ -67,34 +68,45 @@ internal object CompanionAudioBridge {
|
||||
receive(web, raw, source.toString(), main, origins) { proxy.postMessage(it) }
|
||||
}
|
||||
})
|
||||
CompanionAudioDiagnostics.record(Event.BRIDGE_ATTACHED)
|
||||
}
|
||||
internal fun receive(web: WebView, raw: String, source: String, main: Boolean,
|
||||
origins: Set<String>, proxy: (String) -> Unit) {
|
||||
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) return
|
||||
if (raw.length > 96000) return
|
||||
val data = runCatching { JSONObject(raw) }.getOrNull() ?: return
|
||||
CompanionAudioDiagnostics.record(Event.MESSAGE_RECEIVED)
|
||||
if (!cloudVideoSenderAllowed(web.url, source, origins, main)) { CompanionAudioDiagnostics.record(Event.SENDER_REJECTED); return }
|
||||
if (raw.length > 96000) { CompanionAudioDiagnostics.record(Event.MESSAGE_TOO_LARGE); return }
|
||||
val data = runCatching { JSONObject(raw) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_JSON); return }
|
||||
val session = data.optString("session")
|
||||
if (data.optString("action") == "release") {
|
||||
if (web === view && session == state?.session) terminate()
|
||||
if (web === view && session == state?.session) { CompanionAudioDiagnostics.record(Event.SESSION_RELEASED); terminate() }
|
||||
return
|
||||
}
|
||||
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: return
|
||||
if (session in retired) return
|
||||
val incoming = runCatching { CompanionAudioState.parse(data) }.getOrNull() ?: run { CompanionAudioDiagnostics.record(Event.INVALID_STATE); return }
|
||||
if (session in retired) { CompanionAudioDiagnostics.record(Event.RETIRED_SESSION); return }
|
||||
val old = state
|
||||
if (old != null && old.session == session) {
|
||||
if (view !== web || incoming.sequence <= old.sequence) return
|
||||
if (view !== web || incoming.sequence <= old.sequence) { CompanionAudioDiagnostics.record(Event.STALE_STATE); return }
|
||||
} else {
|
||||
if (!incoming.playing) return // Do not start a service for idle metadata.
|
||||
if (!incoming.playing) { CompanionAudioDiagnostics.record(Event.IDLE_STATE); return } // Do not start a service for idle metadata.
|
||||
if (old != null) { command("pause"); retire(old.session) }
|
||||
}
|
||||
CompanionAudioDiagnostics.record(Event.STATE_ACCEPTED)
|
||||
view = web; origin = cloudVideoOrigin(source); reply = proxy
|
||||
state = if (old != null && old.session == session && !data.has("artwork")) incoming.copy(artwork = old.artwork) else incoming; updatedAt = SystemClock.elapsedRealtime()
|
||||
runCatching {
|
||||
val service = CompanionAudioService.instance
|
||||
if (service != null) service.refresh()
|
||||
else ContextCompat.startForegroundService(web.context.applicationContext,
|
||||
Intent(web.context.applicationContext, CompanionAudioService::class.java))
|
||||
else {
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_REQUESTED)
|
||||
ContextCompat.startForegroundService(web.context.applicationContext,
|
||||
Intent(web.context.applicationContext, CompanionAudioService::class.java))
|
||||
}
|
||||
}.onFailure {
|
||||
CompanionAudioDiagnostics.record(when {
|
||||
it is SecurityException -> Event.SERVICE_PERMISSION_DENIED
|
||||
it.javaClass.simpleName == "ForegroundServiceStartNotAllowedException" -> Event.SERVICE_BACKGROUND_START_DENIED
|
||||
else -> Event.SERVICE_REQUEST_FAILED
|
||||
})
|
||||
event("error", "Background playback could not start. Reopen the companion and press Play.")
|
||||
command("pause"); terminate()
|
||||
}
|
||||
@@ -112,7 +124,7 @@ internal object CompanionAudioBridge {
|
||||
runCatching { reply?.invoke(message.toString()) }
|
||||
}
|
||||
fun command(name: String, position: Double? = null) = event("command", name, position)
|
||||
fun release(web: WebView) { if (view === web) { command("stop"); terminate() } }
|
||||
fun release(web: WebView) { if (view === web) { CompanionAudioDiagnostics.record(Event.PAGE_RELEASED); command("stop"); terminate() } }
|
||||
fun terminate() {
|
||||
state?.session?.let(::retire)
|
||||
state = null; view = null; origin = null; reply = null
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.webkit.WebView
|
||||
|
||||
/** Local, memory-only allowlisted status. Never accepts URLs, titles, IDs, or exception text. */
|
||||
internal object CompanionAudioDiagnostics {
|
||||
enum class Event {
|
||||
BRIDGE_ATTACHED, BRIDGE_UNSUPPORTED, NO_APPROVED_ORIGIN, ORIGIN_CHANGED,
|
||||
MESSAGE_RECEIVED, SENDER_REJECTED, MESSAGE_TOO_LARGE, INVALID_JSON, INVALID_STATE,
|
||||
RETIRED_SESSION, STALE_STATE, IDLE_STATE, STATE_ACCEPTED, SERVICE_REQUESTED,
|
||||
SERVICE_REQUEST_FAILED, SERVICE_PERMISSION_DENIED, SERVICE_BACKGROUND_START_DENIED, SERVICE_CREATED, SERVICE_STARTED, FOREGROUND_ACTIVE,
|
||||
SERVICE_FINISHED, SERVICE_DESTROYED, PAGE_RELEASED, SESSION_RELEASED, HEARTBEAT_EXPIRED,
|
||||
}
|
||||
private val counts = linkedMapOf<Event, Long>()
|
||||
private val recent = ArrayDeque<Pair<Long, Event>>()
|
||||
@Synchronized fun record(event: Event) {
|
||||
counts[event] = (counts[event] ?: 0) + 1
|
||||
// Position updates must not displace the useful startup/failure sequence.
|
||||
if (recent.lastOrNull()?.second != event && event !in setOf(Event.MESSAGE_RECEIVED, Event.STATE_ACCEPTED, Event.FOREGROUND_ACTIVE)) {
|
||||
recent.addLast(SystemClock.elapsedRealtime() to event)
|
||||
while (recent.size > 12) recent.removeFirst()
|
||||
}
|
||||
}
|
||||
@Synchronized internal fun events(): String = buildString {
|
||||
counts.forEach { (event, count) -> append("${event.name}: $count\n") }
|
||||
append("Recent transitions (seconds since boot):\n")
|
||||
recent.forEach { (at, event) -> append("${at / 1000}: ${event.name}\n") }
|
||||
}
|
||||
fun report(context: Context): String = buildString {
|
||||
val manager = context.getSystemService(NotificationManager::class.java)
|
||||
append("Companion playback diagnostics v1\n")
|
||||
val app = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
append("App: ${app.versionName}\n")
|
||||
append("Android API: ${Build.VERSION.SDK_INT}\n")
|
||||
append("WebView: ${WebView.getCurrentWebViewPackage()?.versionName ?: "unavailable"}\n")
|
||||
append("Notifications enabled: ${manager.areNotificationsEnabled()}\n")
|
||||
append("Audio channel importance: ${manager.getNotificationChannel("companion-audio")?.importance ?: "not created"}\n")
|
||||
append("Native session: ${CompanionAudioBridge.state != null}\n")
|
||||
append("Native playing: ${CompanionAudioBridge.state?.playing ?: false}\n")
|
||||
append("Service present: ${CompanionAudioService.instance != null}\n")
|
||||
append(events())
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import android.os.SystemClock
|
||||
import android.util.Base64
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.MainActivity
|
||||
import com.archipelago.app.ui.screens.CompanionAudioDiagnostics.Event
|
||||
|
||||
/** Foreground ownership of the existing authenticated WebView player. No stream
|
||||
* URL, auth token or cookie is copied into native playback or notifications. */
|
||||
@@ -46,7 +47,7 @@ class CompanionAudioService : Service() {
|
||||
override fun run() {
|
||||
val state = CompanionAudioBridge.state ?: return
|
||||
val age = SystemClock.elapsedRealtime() - CompanionAudioBridge.updatedAt
|
||||
if (age > 90000) CompanionAudioBridge.stop()
|
||||
if (age > 90000) { CompanionAudioDiagnostics.record(Event.HEARTBEAT_EXPIRED); CompanionAudioBridge.stop() }
|
||||
else {
|
||||
if (age > 15000) CompanionAudioBridge.command("sync")
|
||||
handler.postDelayed(this, 5000)
|
||||
@@ -55,6 +56,7 @@ class CompanionAudioService : Service() {
|
||||
}
|
||||
override fun onCreate() {
|
||||
super.onCreate(); instance = this
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_CREATED)
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, "Audio playback", NotificationManager.IMPORTANCE_LOW))
|
||||
media = MediaSession(this, "Archipelago audio")
|
||||
@@ -80,6 +82,7 @@ class CompanionAudioService : Service() {
|
||||
}
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_STARTED)
|
||||
val state = CompanionAudioBridge.state
|
||||
if (state == null) { finishPlayback(); return START_NOT_STICKY }
|
||||
finishing = false; instance = this
|
||||
@@ -140,6 +143,7 @@ class CompanionAudioService : Service() {
|
||||
.setActions(*controls.toTypedArray())
|
||||
bitmap?.let { notification.setLargeIcon(it) }
|
||||
startForeground(NOTIFICATION, notification.build())
|
||||
CompanionAudioDiagnostics.record(Event.FOREGROUND_ACTIVE)
|
||||
}
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
if (CompanionAudioBridge.state?.playing != true) CompanionAudioBridge.stop()
|
||||
@@ -148,10 +152,12 @@ class CompanionAudioService : Service() {
|
||||
internal fun finishPlayback() {
|
||||
if (finishing) return
|
||||
finishing = true
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_FINISHED)
|
||||
if (instance === this) instance = null
|
||||
stopForeground(STOP_FOREGROUND_REMOVE); stopSelf()
|
||||
}
|
||||
override fun onDestroy() {
|
||||
CompanionAudioDiagnostics.record(Event.SERVICE_DESTROYED)
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
runCatching { unregisterReceiver(noisy) }
|
||||
media.isActive = false; media.release(); bitmap = null
|
||||
|
||||
@@ -58,6 +58,34 @@ class CompanionAudioTest {
|
||||
} finally { CompanionAudioBridge.release(web); lifecycle.destroy(); web.destroy() }
|
||||
}
|
||||
|
||||
@Test fun diagnosticReportExcludesPrivateMessagesAndRecordsRejectionStage() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val web = android.webkit.WebView(app)
|
||||
web.loadUrl("https://private-node.test/cloud?token=private-token")
|
||||
try {
|
||||
CompanionAudioBridge.receive(web, "private-invalid-payload", "https://private-node.test", true,
|
||||
setOf("https://private-node.test")) {}
|
||||
CompanionAudioBridge.receive(web, state().put("title", "PRIVATE SONG").put("duration", -1).toString(),
|
||||
"https://private-node.test", true, setOf("https://private-node.test")) {}
|
||||
val report = CompanionAudioDiagnostics.report(app)
|
||||
assertTrue(report.contains("INVALID_JSON:"))
|
||||
assertTrue(report.contains("INVALID_STATE:"))
|
||||
for (privateValue in listOf("private-node", "private-token", "private-invalid-payload", "PRIVATE SONG", "12345678")) {
|
||||
assertFalse(report.contains(privateValue))
|
||||
}
|
||||
} finally { web.destroy() }
|
||||
}
|
||||
@Test fun diagnosticHistoryIsBoundedWithoutDroppingStageCounts() {
|
||||
repeat(100) {
|
||||
CompanionAudioDiagnostics.record(CompanionAudioDiagnostics.Event.SERVICE_CREATED)
|
||||
CompanionAudioDiagnostics.record(CompanionAudioDiagnostics.Event.SERVICE_DESTROYED)
|
||||
}
|
||||
val report = CompanionAudioDiagnostics.events()
|
||||
val history = report.substringAfter("Recent transitions (seconds since boot):\n")
|
||||
assertEquals(12, history.lines().count { it.isNotBlank() })
|
||||
assertTrue(report.contains("SERVICE_CREATED:"))
|
||||
}
|
||||
|
||||
private fun state() = JSONObject("""{"version":1,"action":"state","session":"12345678-1234-1234-1234-123456789abc","sequence":1,"title":"Current song","playing":true,"position":10,"duration":120,"previous":true,"next":true,"shuffle":true,"shuffled":false}""")
|
||||
@Test fun malformedOrUnboundedMetadataCannotBecomeNativePlayback() {
|
||||
for ((key, value) in listOf("version" to 2, "session" to "foreign", "sequence" to -1,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Companion background audio investigation — 2026-10-07
|
||||
|
||||
Status: **OPEN — physical acceptance failed.**
|
||||
|
||||
APK 0.5.36 (build 56) plays V4V while foregrounded, but the operator reports audio
|
||||
stopping about five seconds after locking the phone or switching applications,
|
||||
with no playback notification. Restarting the companion did not fix it. USB is
|
||||
unavailable. Do not ask for USB again or repeat the restart/demo-track instructions.
|
||||
The V4V track the operator already used is valid; no separate demo is required.
|
||||
|
||||
The 29 Android tests and dashboard/browser native-bridge simulation passed for
|
||||
build 56. Those checks did not establish real WebView-to-Android messaging or
|
||||
OS background playback. Physical evidence overrides the earlier nearly-finished
|
||||
assessment. No confirmed root cause or correction has yet been established.
|
||||
|
||||
## Source investigation
|
||||
|
||||
- The packaged manifest contains the nonexported mediaPlayback foreground service
|
||||
and required permissions. The bridge accepts only the paired dashboard main
|
||||
frame and sends metadata/control, never the authenticated stream URL.
|
||||
- Existing Robolectric tests call the bridge handler directly. Browser tests use
|
||||
a mocked native bridge. Neither tests injection on the affected phone.
|
||||
- Unsupported WebView messaging, origin rejection, malformed state, and absent
|
||||
native messages previously had no user-readable diagnosis. Startup exceptions
|
||||
are handled but only emit a generic page error.
|
||||
- The separate native browser overlay for apps opened outside the dashboard does
|
||||
not attach this dashboard media bridge. Whether the operator used that route
|
||||
is unknown; do not assert this is the cause or broaden trusted origins.
|
||||
- Missing notification does not prove notification permission is the cause. The
|
||||
diagnostic separates whether a native state/service was ever established.
|
||||
|
||||
## Diagnostic build 0.5.37 / 57
|
||||
|
||||
Android-only, with no speculative playback behavior change. Adds Menu → Playback
|
||||
diagnostics, Refresh, and Copy report. The report includes app/Android/WebView
|
||||
versions, notification/channel status, native session/service presence, allowlisted
|
||||
bridge acceptance/rejection and service stage counts, and at most 12 recent stage
|
||||
transitions. No URLs, titles, session identifiers, credentials, raw exceptions,
|
||||
automatic upload, or persistent logging. Counters are in memory and reset on app
|
||||
process restart. The operator chooses whether to copy/share the report.
|
||||
|
||||
Interpretation:
|
||||
|
||||
- BRIDGE_UNSUPPORTED / NO_APPROVED_ORIGIN: bridge did not attach.
|
||||
- BRIDGE_ATTACHED with no MESSAGE_RECEIVED: investigate dashboard client/loading
|
||||
and whether playback opened outside its retained iframe.
|
||||
- SENDER_REJECTED / INVALID_STATE: investigate the corresponding protocol boundary;
|
||||
do not weaken origin or input checks.
|
||||
- SERVICE_REQUESTED without FOREGROUND_ACTIVE: investigate Android service startup.
|
||||
- FOREGROUND_ACTIVE followed by stopping: investigate lifecycle, service ownership,
|
||||
notification settings and WebView background policy with those actual stages.
|
||||
|
||||
The build is diagnostic, **not a fixed/accepted background-audio release**. After
|
||||
installation, play the same V4V track, reproduce once, reopen Menu → Playback
|
||||
diagnostics and copy the report. No need to use a different track or USB.
|
||||
|
||||
## Validation
|
||||
|
||||
- All **33 Android unit/Robolectric tests passed**, zero failures/errors/skips,
|
||||
including SDK 28/35 privacy and bounded-history regressions. XML was preserved
|
||||
before the canonical clean package build.
|
||||
- Canonical clean assemble/sign verification passed v1/v2/v3 and the original
|
||||
signer. APK SHA256:
|
||||
`9b63bd928ab396278791ab3b514dbb53daaf4b7de9311e8e4f505acc02594a2c`.
|
||||
- Delivered and HTTP bytes verified at
|
||||
<http://192.168.63.240/packages/archipelago-companion-0.5.37.apk>.
|
||||
The live dashboard, generic APK/JSON and versioned APK 0.5.36 remained byte-for-byte
|
||||
unchanged. Candidate generic APK/JSON were restored after the canonical script.
|
||||
- 85 captured Android/package source input hashes remained unchanged through
|
||||
packaging. No keystore was staged, copied into evidence, or replaced.
|
||||
- Durable evidence: `~/.local/state/archipelago/release-qualification/companion-057/`.
|
||||
No wallet, payment, catalog, OTA, ISO, or native node-service deployment changed.
|
||||
- Physical background playback remains **unaccepted**; the diagnostic report is
|
||||
the next evidence required from the affected phone.
|
||||
|
||||
## Companion shell routing check
|
||||
|
||||
A read-only browser check on Yaya at 390 px injected `ArchipelagoNative`
|
||||
(openInApp, openInAppEx, openExternal) alongside the mocked audio bridge, then
|
||||
used the existing qualification entry `openSession('node-demo-v4v')` from Discover.
|
||||
It retained one :7475 dashboard iframe, `panelApp` and `mediaApp` were both
|
||||
`node-demo-v4v`, and native navigation calls were empty. Zero signing/payment
|
||||
operations were attempted. Thus the managed entry also retains its host frame
|
||||
when the companion shell is detected. This still does not prove actual Android
|
||||
WebView injection or the affected phone's navigation path.
|
||||
|
||||
An initial Apps-button probe found no V4V card/package on `/dashboard/apps`; it
|
||||
therefore did not exercise an actual Apps launch button. Keep that failed probe
|
||||
distinct from the subsequent successful Discover/launcher check. No routing fix
|
||||
is justified by these results. Scripts/logs are in the durable diagnostic evidence.
|
||||
Reference in New Issue
Block a user