diff --git a/.github/workflows/v2-tests.yml b/.github/workflows/v2-tests.yml index 640019db..2239a689 100644 --- a/.github/workflows/v2-tests.yml +++ b/.github/workflows/v2-tests.yml @@ -236,6 +236,10 @@ jobs: working-directory: v2 run: LAUNCHER_CURATION_EVIDENCE=1 npm run test:launcher-rows + - name: Launcher feedback + working-directory: v2 + run: LAUNCHER_FEEDBACK_EVIDENCE=1 npm run test:launcher-feedback + # Right-click shows the app menu, not the webview's link menu (#129), # while text fields keep their native Cut / Copy / Paste. - name: Context menu @@ -259,6 +263,8 @@ jobs: v2/evidence/launcher-curation-2026-10-23/launcher-states-simulated.webm v2/evidence/tvrecommendations-safety/*-simulated.png v2/evidence/tvrecommendations-safety/caution-review-simulated.webm + v2/evidence/launcher-feedback-212/*-simulated.png + v2/evidence/launcher-feedback-212/launcher-feedback-simulated.webm retention-days: 7 if-no-files-found: ignore diff --git a/v2/evidence/launcher-feedback-212/.gitignore b/v2/evidence/launcher-feedback-212/.gitignore new file mode 100644 index 00000000..b753dc29 --- /dev/null +++ b/v2/evidence/launcher-feedback-212/.gitignore @@ -0,0 +1,3 @@ +raw/ +*-simulated.png +*-simulated.webm diff --git a/v2/evidence/launcher-feedback-212/README.md b/v2/evidence/launcher-feedback-212/README.md new file mode 100644 index 00000000..7d686412 --- /dev/null +++ b/v2/evidence/launcher-feedback-212/README.md @@ -0,0 +1,49 @@ +# Issue212 launcher feedback evidence + +All screenshots and the 15.84-second video are **SIMULATED**, generated locally +with the demo fixture and a test-only invoke wrapper. No real phone/TV operation, +APK, identity migration, release or deployment was performed. The reporter's +screenshot was inspected but is not redistributed here because it contains their +local device address. It shows v2.3.0 and a Google TV Streamer on Android14; the LG +G3 display in the report does not establish the Android host's firmware. + +The confirmed defect is missing visible feedback, not established launcher +failure: the old UI placed results below the entire launcher list and Advanced +picker, and a refresh error hid the result and diagnostics entirely. The new +status is beside the clicked row, including when that row becomes Current +default. When refresh fails, the result remains above the error with an explicit +stale-state qualification. Diagnostic copy fallback also remains visible. +Backend success, refusal and unverified outcomes retain their distinct meaning; +a connection exception says the result cannot be confirmed. Initial progress is +visible before backend channel messages. Replies after leaving/changing the page +context do not update the new page or trigger stale refreshes. + +`tests/launcher-feedback.mjs` covers 11 simulated cases: + +- Verified success, refusal, accepted command with unchanged Home, and missing + verification/error detail. +- Successful command followed by list/current-home/channel-provider refresh + failure, plus refusal followed by refresh failure and clipboard denial. +- Interrupted command, cancelled takeover, and leaving while a command is pending. + +Each completed attempt asserts exactly one original set-default command with +`allowStockDisable=false`; no added disable or takeover command is allowed. +Cancelling the existing takeover prompt never retries with opt-in enabled. The +backend and safety engine are unchanged; this does not prove device behavior. + +Local validation passed: all 11 feedback cases, existing launcher-row/Advanced +picker/snapshot/ClearTV regressions, Svelte check with 0 errors and 0 warnings, +production frontend build, and regenerated 16 dark + 16 light gallery frames/GIFs. +Exact-head CI and independent review are the merge gates. No claim of +cross-platform or real-device validation is made from local browser checks. + +Evidence is attached directly to [PR213’s evidence comment](https://github.com/bryanroscoe/shield_optimizer/pull/213#issuecomment-6355858736), captured at `fccb8da03195218f6f2ca3ed1723426585744214`. +All ten screenshots and the short video were downloaded from those attachments and verified byte-for-byte against the committed originals before duplicate media were removed in a normal follow-up commit. `attachments.json` records each durable attachment URL, SHA256 and byte length; `visual-sha256.json` preserves the original capture hashes. The earlier commit retains the original files without rewriting history. + +The attachment migration changes no runtime or test behavior. Regenerated media remain local/CI artifacts and are ignored by Git. Use `LAUNCHER_FEEDBACK_EVIDENCE=1 npm run test:launcher-feedback` to recreate simulated captures; subsequent runs may differ visually from the original capture hashes. + +The migration-sensitive `com.shieldoptimizer.app` identifier and data paths remain +unchanged. PR211's experiment merge hold, PR180's approval gate and PR163's +physical/release acceptance gates remain separate. Issue212's actual device +launcher outcome is unverified; no reporter comment was needed to fix the +confirmed presentation defect. diff --git a/v2/evidence/launcher-feedback-212/attachments.json b/v2/evidence/launcher-feedback-212/attachments.json new file mode 100644 index 00000000..5cc61aa0 --- /dev/null +++ b/v2/evidence/launcher-feedback-212/attachments.json @@ -0,0 +1,72 @@ +{ + "comment": "https://github.com/bryanroscoe/shield_optimizer/pull/213#issuecomment-6355858736", + "captured_revision": "fccb8da03195218f6f2ca3ed1723426585744214", + "assets": [ + { + "file": "cancel-takeover-simulated.png", + "url": "https://github.com/user-attachments/assets/7d99d759-80d5-419c-8b9d-413384b25930", + "sha256": "c7d7a57cfd9eda0eab47f83028f61bf40035779c73b04861b2fd0c53b7575528", + "bytes": 155783 + }, + { + "file": "channel-refresh-simulated.png", + "url": "https://github.com/user-attachments/assets/3eea0cd7-349b-4854-afdd-fbe95954149d", + "sha256": "fbd516f911f0b1e8a8700155fbfe686b234163cac01298bbdd8b092466d5fdea", + "bytes": 83215 + }, + { + "file": "current-refresh-simulated.png", + "url": "https://github.com/user-attachments/assets/9e867d1d-c25a-4d31-9f43-cff2c703878d", + "sha256": "5fd6eb467eb5674f511387999d995d88accd9f8463bcf233ee30366e64bf4281", + "bytes": 82942 + }, + { + "file": "failure-refresh-simulated.png", + "url": "https://github.com/user-attachments/assets/614e1021-e793-4821-936c-287149131740", + "sha256": "7c756bed07cd66f0c3a1cde3182a9088372c71695284f77929482e5f9b845b4b", + "bytes": 97889 + }, + { + "file": "failure-simulated.png", + "url": "https://github.com/user-attachments/assets/edd0c740-0c39-4057-a902-2eaf21d85f28", + "sha256": "e6ba46266eefc8f767788cf419b37b097edb57a781a7c0a1f111aa5948f53f83", + "bytes": 156510 + }, + { + "file": "interrupted-simulated.png", + "url": "https://github.com/user-attachments/assets/5f65da88-df66-4021-b716-531eaf5f9cff", + "sha256": "978a1dac009390949727f15466e31b9875776ce3f67513ac84c7fe8d24103d8a", + "bytes": 154611 + }, + { + "file": "list-refresh-simulated.png", + "url": "https://github.com/user-attachments/assets/a718829b-8ae2-4231-836e-06c60fcad98f", + "sha256": "ae36f118aa14cdafce3ceb8de979eb3c42d523a78d8015fc42e80a95a57ace75", + "bytes": 83570 + }, + { + "file": "noop-simulated.png", + "url": "https://github.com/user-attachments/assets/74320570-63cd-4446-8c21-df73d3fdbc67", + "sha256": "2be452869f30f331d3c02df6275a4ed3a465b4a6627e9c6d978067ec6b8dd4e3", + "bytes": 158606 + }, + { + "file": "success-simulated.png", + "url": "https://github.com/user-attachments/assets/dff006e8-8331-4726-baec-b8dfc0f68573", + "sha256": "4c625538d3ef2de5b33685b8fef28067fb2ed75bfa9f4387e89f13a6366f44ec", + "bytes": 153985 + }, + { + "file": "unverified-simulated.png", + "url": "https://github.com/user-attachments/assets/daa8d5e5-edd4-412f-bfb2-1bdb2a6f00c7", + "sha256": "361e8c666fbffe21821d19b99215b81f46f8f0c6ce00f2ed3461be87f0f98c9d", + "bytes": 159395 + }, + { + "file": "launcher-feedback-simulated.webm", + "url": "https://github.com/user-attachments/assets/24131cf2-561e-4ed4-84ba-c097efc1508f", + "sha256": "29584b91a439dd4d9da268754dd80eef112f1fada04eba8836a57fc42f726137", + "bytes": 1948306 + } + ] +} diff --git a/v2/evidence/launcher-feedback-212/visual-sha256.json b/v2/evidence/launcher-feedback-212/visual-sha256.json new file mode 100644 index 00000000..ef99fc92 --- /dev/null +++ b/v2/evidence/launcher-feedback-212/visual-sha256.json @@ -0,0 +1,13 @@ +{ + "cancel-takeover-simulated.png": "c7d7a57cfd9eda0eab47f83028f61bf40035779c73b04861b2fd0c53b7575528", + "channel-refresh-simulated.png": "fbd516f911f0b1e8a8700155fbfe686b234163cac01298bbdd8b092466d5fdea", + "current-refresh-simulated.png": "5fd6eb467eb5674f511387999d995d88accd9f8463bcf233ee30366e64bf4281", + "failure-refresh-simulated.png": "7c756bed07cd66f0c3a1cde3182a9088372c71695284f77929482e5f9b845b4b", + "failure-simulated.png": "e6ba46266eefc8f767788cf419b37b097edb57a781a7c0a1f111aa5948f53f83", + "interrupted-simulated.png": "978a1dac009390949727f15466e31b9875776ce3f67513ac84c7fe8d24103d8a", + "launcher-feedback-simulated.webm": "29584b91a439dd4d9da268754dd80eef112f1fada04eba8836a57fc42f726137", + "list-refresh-simulated.png": "ae36f118aa14cdafce3ceb8de979eb3c42d523a78d8015fc42e80a95a57ace75", + "noop-simulated.png": "2be452869f30f331d3c02df6275a4ed3a465b4a6627e9c6d978067ec6b8dd4e3", + "success-simulated.png": "4c625538d3ef2de5b33685b8fef28067fb2ed75bfa9f4387e89f13a6366f44ec", + "unverified-simulated.png": "361e8c666fbffe21821d19b99215b81f46f8f0c6ce00f2ed3461be87f0f98c9d" +} diff --git a/v2/package.json b/v2/package.json index ad359e22..e68e1f7e 100644 --- a/v2/package.json +++ b/v2/package.json @@ -38,7 +38,8 @@ "tauri": "tauri", "subset-icons": "python3 scripts/subset-material-symbols.py", "test:device-logs": "node tests/log-poller.mjs && node tests/device-logs.mjs", - "test:tvrecommendations-safety": "node tests/tvrecommendations-safety.mjs" + "test:tvrecommendations-safety": "node tests/tvrecommendations-safety.mjs", + "test:launcher-feedback": "node tests/launcher-feedback.mjs" }, "license": "UNLICENSED", "dependencies": { diff --git a/v2/screenshots/gallery-light.gif b/v2/screenshots/gallery-light.gif index cccb19b2..070ac911 100644 Binary files a/v2/screenshots/gallery-light.gif and b/v2/screenshots/gallery-light.gif differ diff --git a/v2/screenshots/gallery.gif b/v2/screenshots/gallery.gif index 596f82ed..9a1285b1 100644 Binary files a/v2/screenshots/gallery.gif and b/v2/screenshots/gallery.gif differ diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index a33c9545..97466dd5 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -196,6 +196,8 @@ let launcherErr = $state(null); let launcherActionBusy = $state(null); // package id currently being acted on let launcherActionMessage = $state(""); + let launcherSetResult = $state<{ package: string; message: string } | null>(null); + let launcherRequest = 0; /// Per-stage record from the last failed launcher switch, offered as a copy /// button. Kept out of the message itself — it is for a bug report, not for /// reading on screen. @@ -650,22 +652,29 @@ }); async function loadLauncher() { + const context = capturePageContext(); + const request = ++launcherRequest; launcherLoading = true; launcherErr = null; try { const [list, cur, chan] = await Promise.all([ - api.listLaunchers(serial), - api.currentLauncher(serial), - api.channelProviderDisabled(serial), + api.listLaunchers(context.serial), + api.currentLauncher(context.serial), + api.channelProviderDisabled(context.serial), ]); + if (!pageContextIsCurrent(context) || request !== launcherRequest) return false; launchers = list; currentLauncher = cur; channelDisabled = chan; + return true; } catch (e) { - launcherErr = String(e); + if (pageContextIsCurrent(context) && request === launcherRequest) launcherErr = String(e); + return false; } finally { - launcherLoading = false; - launchersLoaded = true; + if (pageContextIsCurrent(context) && request === launcherRequest) { + launcherLoading = false; + launchersLoaded = true; + } } } @@ -1310,6 +1319,7 @@ } async function installLauncherFromStore(pkg: string) { + launcherSetResult = null; launcherActionBusy = pkg; launcherActionMessage = ""; stopStorePoll(); @@ -1371,6 +1381,7 @@ let stockHoldsHomeFor = $state(null); async function setHomeFromPicker() { + launcherSetResult = null; const pkg = homePickerChoice; if (!pkg) return; homePickerBusy = "set"; @@ -1393,6 +1404,7 @@ } async function disableStockFromPicker(saveFirst: boolean) { + launcherSetResult = null; const pkg = homePickerChoice; if (!pkg) return; stockConfirmOpen = false; @@ -1428,6 +1440,7 @@ } async function enableLauncher(pkg: string) { + launcherSetResult = null; const name = launchers.find((l) => l.entry.package === pkg)?.entry.name ?? pkg; const prevDefault = currentLauncher?.package ?? null; const prevName = prevDefault @@ -1517,6 +1530,7 @@ ); async function turnOffSetupHelper() { + launcherSetResult = null; const row = setupHelper; if (!row) return; const pkg = row.entry.package; @@ -1539,6 +1553,7 @@ } async function disableLauncher(pkg: string) { + launcherSetResult = null; const row = launchers.find((l) => l.entry.package === pkg); const name = row?.entry.name ?? pkg; const advice = row?.other @@ -1587,21 +1602,25 @@ } async function setDefaultLauncher(pkg: string) { + if (launcherActionBusy !== null) return; + const context = capturePageContext(); const name = launchers.find((l) => l.entry.package === pkg)?.entry.name ?? pkg; launcherActionBusy = pkg; launcherActionMessage = ""; + launcherSetResult = null; launcherDiagnostics = []; launcherDiagnosticsCopied = false; - launcherProgress = ""; + launcherProgress = "Setting default launcher"; // The backend works through several strategies (enable → role → set-home- // activity → verify) that can take a few seconds; narrate each step so the // user knows it's working rather than hung. const onProgress = new Channel(); onProgress.onmessage = (step) => { - if (launcherActionBusy === pkg) launcherProgress = step; + if (pageContextIsCurrent(context) && launcherActionBusy === pkg) launcherProgress = step; }; try { - let r = await api.setDefaultLauncher(serial, pkg, false, onProgress); + let r = await api.setDefaultLauncher(context.serial, pkg, false, onProgress); + if (!pageContextIsCurrent(context)) return; if (!r.ok && r.stock_takeover_available) { // The only working method on this build disables the stock launcher. // Never do that silently — ask, then retry with the opt-in flag. @@ -1609,22 +1628,24 @@ const proceed = confirm( `${r.last_error ?? "This device ignores the standard launcher-switch commands."}\n\nDisable the stock launcher and switch to ${name}? You can re-enable it from this list at any time.`, ); - if (proceed) r = await api.setDefaultLauncher(serial, pkg, true, onProgress); + if (proceed) r = await api.setDefaultLauncher(context.serial, pkg, true, onProgress); } + if (!pageContextIsCurrent(context)) return; + let message: string; if (r.ok) { - launcherActionMessage = + message = r.strategy === "disable_stock_takeover" ? `${name} is now your default launcher — the stock launcher was disabled to hand it over. Re-enable it from this list any time.` : `${name} is now your default launcher.`; // A successful switch only carries `last_error` as a follow-up note // (e.g. the setup helper couldn't be re-enabled). - if (r.last_error) launcherActionMessage += ` ${r.last_error}`; + if (r.last_error) message += ` ${r.last_error}`; } else { // Backend messages are full sentences (including the "device accepted // the change — press Home" case) — render them verbatim rather than // prefixing "Failed:", which once produced "Failed: Success". - launcherActionMessage = - r.last_error ?? "Could not set default launcher. Try disabling other launchers first."; + message = + r.last_error ?? `Android did not verify ${name} as default. No success was confirmed.`; // Only on failure: this is the record a reporter can paste back, and // it is the only thing that distinguishes "the device refused the // command" from "the device accepted it and ignored it". @@ -1636,15 +1657,24 @@ // is itself a few ADB queries, so keep the row's status line alive for it // rather than leaving the spinner frozen on the last backend step. if (launcherActionBusy === pkg) launcherProgress = "Refreshing the launcher list"; - await loadLauncher(); + launcherSetResult = { package: pkg, message }; + const refreshed = await loadLauncher(); + if (!pageContextIsCurrent(context)) return; + if (!refreshed) { + launcherSetResult = { package: pkg, message: `${message} Launcher status could not be refreshed; the displayed state may be stale. ${launcherErr ?? "Refresh to retry."}` }; + } // The takeover may have disabled stock; either way launcher state changed, // so the Memory tab's report is stale. invalidateDeviceCaches(); } catch (e) { - launcherActionMessage = String(e); + if (pageContextIsCurrent(context)) { + launcherSetResult = { package: pkg, message: `Could not confirm whether ${name} became default: ${e}. Refresh the launcher list to check its current state.` }; + } } finally { - launcherActionBusy = null; - launcherProgress = ""; + if (pageContextIsCurrent(context)) { + launcherActionBusy = null; + launcherProgress = ""; + } } } @@ -1940,7 +1970,8 @@ report = null; reportErr = null; reportLastRefreshed = null; memorySafety = {}; memoryConfirmed = new Set(); launchers = []; launchersLoaded = false; currentLauncher = null; channelDisabled = null; - launcherErr = null; launcherActionMessage = ""; + launcherErr = null; launcherActionMessage = ""; launcherSetResult = null; + launcherActionBusy = null; launcherProgress = ""; launcherLoading = false; launcherDiagnostics = []; stopStorePoll(); storeOpened = null; homePickerPackages = []; homePickerErr = null; homePickerChoice = ""; homePickerActivity = ""; homePickerMessage = ""; homePickerOk = false; stockConfirmOpen = false; stockHoldsHomeFor = null; @@ -2596,6 +2627,21 @@ {launcherLoading ? "Loading…" : "Refresh"} + {#if launcherSetResult && (launcherErr || !launchers.some((l) => l.entry.package === launcherSetResult?.package))} +

{launcherSetResult.message}

+ {/if} + {#if launcherDiagnostics.length > 0} +

+ + — every command this attempt ran and what your TV replied. Paste it into a + GitHub issue; it is what makes a launcher report fixable. +

+ {/if} + {#if launcherActionMessage} +

{launcherActionMessage}

+ {/if} {#if launcherErr}
{launcherErr}
{:else} @@ -2621,6 +2667,9 @@
{l.entry.name}
{l.entry.package}
+ {#if launcherSetResult?.package === l.entry.package} +

{launcherSetResult.message}

+ {/if} {#if busy && launcherProgress}
{launcherProgress}… @@ -2879,18 +2928,6 @@ {/if}
- {#if launcherActionMessage} -

{launcherActionMessage}

- {/if} - {#if launcherDiagnostics.length > 0} -

- - — every command this attempt ran and what your TV replied. Paste it into a - GitHub issue; it is what makes a launcher report fixable. -

- {/if} {/if} {/if}
@@ -3551,6 +3588,7 @@ {/if}