diff --git a/v2/CHANGELOG.md b/v2/CHANGELOG.md index 4ec1fa1..0b98e12 100644 --- a/v2/CHANGELOG.md +++ b/v2/CHANGELOG.md @@ -65,6 +65,20 @@ this file and shows the newest few sections, dated, from its version button. ### Fixed +- **Disabling the stock launcher on Google TV now turns off Setup Wraith too, so + it can't take the Home button back** (#122). Google TV's setup helper + (`com.google.android.tungsten.setupwraith`) declares a Home screen, and with + the stock launcher gone it became the fallback Home. The takeover now + disables it together with stock, only after your launcher is confirmed as + Home, and checks again afterwards. If anything fails, stock and Setup Wraith + are both re-enabled, as is re-enabling stock from the Launcher tab. The pairing lives in `launchers.json`, so a Shield, which + has no Setup Wraith, behaves exactly as before. Emergency Recovery re-enables + both. The Launcher tab now always says whether Setup Wraith is on or off, warns + when it is on with stock off, offers a one-click **Turn it off** (guarded and + verified like the takeover) and a **Re-enable Setup Wraith** button for when + Google asks you to sign in again or you need to pair a remote. The confirm + names both effects, and the bug-report bundle records its state. + - **An app report no longer carries state from before an Optimize run** (#138). Right after a run, the page re-reads package states in the background. A report opened in that window with state included could carry diff --git a/v2/crates/core/data/app-lists/launchers.json b/v2/crates/core/data/app-lists/launchers.json index 7034476..c271502 100644 --- a/v2/crates/core/data/app-lists/launchers.json +++ b/v2/crates/core/data/app-lists/launchers.json @@ -43,7 +43,8 @@ }, { "name": "Google TV Home (Stock)", - "package": "com.google.android.apps.tv.launcherx" + "package": "com.google.android.apps.tv.launcherx", + "disable_with": ["com.google.android.tungsten.setupwraith"] }, { "name": "Leanback Launcher (Stock)", diff --git a/v2/crates/core/src/adb/sim/device.rs b/v2/crates/core/src/adb/sim/device.rs index 9807de1..e47c081 100644 --- a/v2/crates/core/src/adb/sim/device.rs +++ b/v2/crates/core/src/adb/sim/device.rs @@ -67,6 +67,11 @@ pub enum HomePolicy { /// role. Google TV with Setup Wraith enabled (#122): the resolver names /// Setup Wraith even after the role, and Home, moved to Monet. PriorityResolver, + /// Google TV with Setup Wraith enabled (#122): an enabled stock launcher + /// overrides every preference, and once stock is disabled the highest + /// priority HOME filter (Setup Wraith) takes Home back from the app that + /// holds the role, until Setup Wraith is disabled too. + StockThenPriority, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -242,16 +247,23 @@ impl Device { } let handlers: Vec = self.home_handlers().into_iter().cloned().collect(); let real: Vec<&HomeComponent> = handlers.iter().filter(|c| c.priority > -1000).collect(); - if self.home.policy == HomePolicy::PriorityResolver { - if let Some(top) = real.first() { - return Some(top.short()); - } - } - if self.home.policy == HomePolicy::StockOverrides { + let policy = self.home.policy; + if matches!( + policy, + HomePolicy::StockOverrides | HomePolicy::StockThenPriority + ) { if let Some(s) = real.iter().find(|c| stock.contains(&c.package)) { return Some(s.short()); } } + if matches!( + policy, + HomePolicy::PriorityResolver | HomePolicy::StockThenPriority + ) { + if let Some(top) = real.first() { + return Some(top.short()); + } + } if let Some(pref) = &self.home.preferred { if let Some(c) = real.iter().find(|c| c.matches(pref)) { return Some(c.short()); diff --git a/v2/crates/core/src/adb/sim/tests.rs b/v2/crates/core/src/adb/sim/tests.rs index 17add61..af8b3b4 100644 --- a/v2/crates/core/src/adb/sim/tests.rs +++ b/v2/crates/core/src/adb/sim/tests.rs @@ -263,3 +263,176 @@ async fn setup_wraith_outranking_the_role_holder_is_not_the_current_launcher() { .unwrap(); assert!(!wraith.ok); } + +const WRAITH_PKG: &str = "com.google.android.tungsten.setupwraith"; +const GTV_HOME: &str = "com.google.android.apps.tv.launcherx"; +const PROJECTIVY_PKG: &str = "com.spocky.projengmenu"; +const SHIELD_SERIAL: &str = "1324619053514"; + +/// The reporter's onn 4K Pro (#122): stock Google TV Home enabled, Setup +/// Wraith enabled with the highest HOME priority, and Projectivy installed. +fn google_tv_world(with_wraith: bool) -> SimulatedAdb { + let sim = shield_world(); + { + let mut w = sim.world(); + let d = w.devices.get_mut(SHIELD_SERIAL).unwrap(); + d.props.insert("ro.build.version.sdk".into(), "31".into()); + d.home.policy = HomePolicy::StockThenPriority; + d.add_home_app(GTV_HOME, &format!("{GTV_HOME}.home.HomeActivity"), 0); + if with_wraith { + d.add_home_app(WRAITH_PKG, &format!("{WRAITH_PKG}.ui.MainActivity"), 3); + } + d.add_home_app( + PROJECTIVY_PKG, + &format!("{PROJECTIVY_PKG}.ui.home.MainActivity"), + 0, + ); + } + sim +} + +async fn pick_projectivy_and_disable_stock(sim: &SimulatedAdb) -> launcher::SetLauncherResult { + let st = state(sim); + let serial = "192.0.2.1:5555"; + launcher::set_home_any_impl(&st, serial, PROJECTIVY_PKG, None) + .await + .unwrap(); + launcher::disable_stock_launcher_impl(&st, serial, PROJECTIVY_PKG, &launcher::Progress::Silent) + .await + .unwrap() +} + +fn enabled(sim: &SimulatedAdb, pkg: &str) -> bool { + sim.world() + .devices + .get(SHIELD_SERIAL) + .unwrap() + .package(pkg) + .unwrap() + .enabled +} + +/// #122: with stock disabled, Setup Wraith takes the Home key back unless the +/// takeover turns it off too. +#[tokio::test] +async fn google_tv_takeover_disables_setup_wraith_with_stock() { + let sim = google_tv_world(true); + let res = pick_projectivy_and_disable_stock(&sim).await; + assert!(res.ok, "{:?} {:?}", res.last_error, res.diagnostics); + assert!(!enabled(&sim, GTV_HOME)); + assert!(!enabled(&sim, WRAITH_PKG)); + let stock = super::stock_launchers(); + let mut w = sim.world(); + let d = w.devices.get_mut(SHIELD_SERIAL).unwrap(); + assert!(d.resolve_home(&stock).unwrap().starts_with(PROJECTIVY_PKG)); +} + +/// The simulator models the reporter's failure: without the paired disable, +/// Setup Wraith wins the resolver once stock is gone. +#[tokio::test] +async fn google_tv_without_the_paired_disable_wraith_takes_home_back() { + let sim = google_tv_world(true); + sim.world() + .setup_shell( + SHIELD_SERIAL, + &format!("pm disable-user --user 0 {GTV_HOME}"), + ) + .unwrap(); + let stock = super::stock_launchers(); + let mut w = sim.world(); + let d = w.devices.get_mut(SHIELD_SERIAL).unwrap(); + assert!(d.resolve_home(&stock).unwrap().starts_with(WRAITH_PKG)); +} + +/// A failure while turning Setup Wraith off re-enables stock and Setup Wraith. +#[tokio::test] +async fn google_tv_takeover_failure_re_enables_stock_and_wraith() { + let sim = google_tv_world(true); + sim.world().faults.push(FaultRule { + serial: None, + scope: FaultScope::Shell, + matches: format!("pm disable-user --user 0 {WRAITH_PKG}"), + effect: FaultEffect::Fail { + stdout: String::new(), + stderr: "java.lang.SecurityException: Permission denial\n".into(), + exit_code: 255, + }, + times: None, + after: 0, + fired: 0, + }); + let res = pick_projectivy_and_disable_stock(&sim).await; + assert!(!res.ok); + assert!(enabled(&sim, GTV_HOME), "stock re-enabled"); + assert!(enabled(&sim, WRAITH_PKG), "Setup Wraith re-enabled"); +} + +/// A device without Setup Wraith behaves exactly as before: no command ever +/// names the setup helper. +#[tokio::test] +async fn takeover_without_setup_wraith_never_touches_it() { + let sim = google_tv_world(false); + let res = pick_projectivy_and_disable_stock(&sim).await; + assert!(res.ok, "{:?} {:?}", res.last_error, res.diagnostics); + assert!(!format!("{:?}", sim.world().log).contains("setupwraith")); + assert!(!res.diagnostics.iter().any(|l| l.contains("setupwraith"))); +} + +/// Shield path: stock is `com.google.android.tvlauncher`, which pairs with +/// nothing, so the takeover never names the setup helper. +#[tokio::test] +async fn shield_takeover_is_unchanged() { + let sim = shield_world(); + { + let mut w = sim.world(); + let d = w.devices.get_mut(SHIELD_SERIAL).unwrap(); + d.home.policy = HomePolicy::StockOverrides; + } + sim.world() + .setup_shell(SHIELD_SERIAL, "pm enable com.google.android.tvlauncher") + .unwrap(); + let res = pick_projectivy_and_disable_stock(&sim).await; + assert!(res.ok, "{:?} {:?}", res.last_error, res.diagnostics); + assert!(!format!("{:?}", sim.world().log).contains("setupwraith")); +} + +/// The one-click fix: refuses when no real launcher is left, otherwise +/// disables Setup Wraith and keeps Home on a launcher. +#[tokio::test] +async fn disable_setup_helper_is_guarded_and_verified() { + let sim = google_tv_world(true); + let st = state(&sim); + let serial = "192.0.2.1:5555"; + sim.world() + .setup_shell( + SHIELD_SERIAL, + &format!("pm disable-user --user 0 {GTV_HOME}"), + ) + .unwrap(); + sim.world() + .setup_shell( + SHIELD_SERIAL, + &format!("pm disable-user --user 0 {PROJECTIVY_PKG}"), + ) + .unwrap(); + let refused = launcher::disable_setup_helper_impl(&st, serial, WRAITH_PKG) + .await + .unwrap(); + assert!(!refused.ok, "{}", refused.message); + assert!(enabled(&sim, WRAITH_PKG)); + + sim.world() + .setup_shell(SHIELD_SERIAL, &format!("pm enable {PROJECTIVY_PKG}")) + .unwrap(); + let ok = launcher::disable_setup_helper_impl(&st, serial, WRAITH_PKG) + .await + .unwrap(); + assert!(ok.ok, "{}", ok.message); + assert!(!enabled(&sim, WRAITH_PKG)); + + let not_helper = launcher::disable_setup_helper_impl(&st, serial, PROJECTIVY_PKG) + .await + .unwrap(); + assert!(!not_helper.ok); + assert!(enabled(&sim, PROJECTIVY_PKG)); +} diff --git a/v2/crates/core/src/commands/launcher.rs b/v2/crates/core/src/commands/launcher.rs index 4d0adc2..0503e56 100644 --- a/v2/crates/core/src/commands/launcher.rs +++ b/v2/crates/core/src/commands/launcher.rs @@ -4,8 +4,8 @@ use serde::Serialize; use tauri::State; use crate::engine::{ - is_last_enabled_home_handler, is_valid_package_name, launcher_rows, pick_current_home, - HomeReading, LauncherStatus, + is_last_enabled_home_handler, is_valid_package_name, launcher_rows, paired_transient_holders, + pick_current_home, HomeReading, LauncherStatus, }; use crate::license::Feature; @@ -579,19 +579,38 @@ pub async fn disable_stock_launcher_impl( .await .confirmed { - return Ok(SetLauncherResult { - ok: true, - strategy: Some("disable_stock_takeover".into()), - current_launcher: Some(target.to_string()), - last_error: None, - stock_takeover_available: false, - diagnostics, - }); + match disable_paired_holders( + &*adb, + serial, + target, + &disabled, + Some(&handlers), + progress, + &mut diagnostics, + ) + .await + { + Ok(()) => { + return Ok(SetLauncherResult { + ok: true, + strategy: Some("disable_stock_takeover".into()), + current_launcher: Some(target.to_string()), + last_error: None, + stock_takeover_available: false, + diagnostics, + }); + } + Err(reason) => failure = Some(reason), + } } + let mut unrestored: Vec = Vec::new(); for stock in &disabled { let restore = adb.shell(serial, &format!("pm enable {stock}")).await; diagnostics.push(match command_failure(&restore) { - Some(f) => format!("pm enable {stock} (restore) -> {f}"), + Some(f) => { + unrestored.push(stock.clone()); + format!("pm enable {stock} (restore) -> {f}") + } None => format!("pm enable {stock} (restore) -> ok"), }); } @@ -602,10 +621,19 @@ pub async fn disable_stock_launcher_impl( Ok(refuse( current, format!( - "{}. The stock launcher was re-enabled.", + "{}. {}", failure.unwrap_or_else(|| format!( "Home moved away from {target} after the stock launcher was disabled" - )) + )), + if unrestored.is_empty() { + "The stock launcher was re-enabled.".to_string() + } else { + format!( + "Re-enabling the stock launcher failed ({}); it may still be off. \ + Use Emergency Recovery or re-enable it from the launcher list.", + unrestored.join(", ") + ) + } ), diagnostics, )) @@ -1044,6 +1072,214 @@ pub async fn set_default_launcher_impl( }) } +/// After `target` is confirmed as Home with `stocks` disabled, also disable +/// the transient HOME holders the catalog pairs with those stock launchers +/// (Setup Wraith on Google TV, #122): with stock gone they take the Home button +/// back. A device with no such holder enabled is untouched. +/// +/// Every guard of the stock takeover applies: the do-not-disable gate, the +/// last-Home-handler check, and a second verification that `target` still holds +/// Home. On any failure the holders disabled here are re-enabled before this +/// returns `Err`, and the caller re-enables stock, so the TV is never left +/// switched halfway. +async fn disable_paired_holders( + adb: &dyn crate::adb::AdbDriver, + serial: &str, + target: &str, + stocks: &[String], + inventory: Option<&[String]>, + progress: &Progress, + diagnostics: &mut Vec, +) -> Result<(), String> { + if stocks + .iter() + .all(|s| launchers().disable_with_for(s).is_empty()) + { + return Ok(()); + } + // The Home-handler list read before stock was disabled. Without it the + // helper can't be checked, and leaving it on with stock off is the #122 + // state, so that is a failure the caller rolls back. + let Some(enabled) = inventory else { + diagnostics.push("query-activities HOME (setup helper) -> unavailable".to_string()); + return Err( + "Couldn't read this TV's Home apps before disabling stock, so the setup helper \ + couldn't be checked" + .to_string(), + ); + }; + let mut disabled: Vec = Vec::new(); + let mut failure = None; + for holder in paired_transient_holders(launchers(), stocks, enabled) { + if matches!( + crate::engine::classify_safety(&holder), + crate::engine::Safety::NeverDisable { .. } + ) { + diagnostics.push(format!("{holder} skipped: on the do-not-disable list")); + continue; + } + let remaining: Vec = enabled + .iter() + .filter(|h| !stocks.contains(h)) + .cloned() + .collect(); + if is_last_enabled_home_handler(&holder, &remaining, launchers()) + || !remaining.iter().any(|h| h == target) + { + diagnostics.push(format!("{holder} skipped: it would leave no Home app")); + continue; + } + progress.step("Turning off Google TV's setup helper"); + let result = adb + .shell(serial, &format!("pm disable-user --user 0 {holder}")) + .await; + // An errored disable may still have landed; restore it too. + disabled.push(holder.clone()); + match command_failure(&result) { + Some(f) => { + diagnostics.push(format!("pm disable-user {holder} -> {f}")); + failure = Some(format!( + "Setup-helper disable command failed for {holder}: {f}" + )); + break; + } + None => diagnostics.push(format!("pm disable-user {holder} -> ok")), + } + } + if disabled.is_empty() { + return Ok(()); + } + if failure.is_none() { + let _ = adb + .shell( + serial, + "am start -W -a android.intent.action.MAIN -c android.intent.category.HOME", + ) + .await; + if verify_active(adb, serial, target, diagnostics) + .await + .confirmed + { + return Ok(()); + } + failure = Some(format!( + "Home moved away from {target} after the setup helper was disabled" + )); + } + let mut unrestored: Vec = Vec::new(); + for holder in &disabled { + let restore = adb.shell(serial, &format!("pm enable {holder}")).await; + diagnostics.push(match command_failure(&restore) { + Some(f) => { + unrestored.push(holder.clone()); + format!("pm enable {holder} (restore) -> {f}") + } + None => format!("pm enable {holder} (restore) -> ok"), + }); + } + let mut reason = failure.unwrap_or_default(); + if !unrestored.is_empty() { + reason.push_str(&format!( + ". Setup Wraith may still be off ({}). Use Re-enable Setup Wraith or Emergency Recovery", + unrestored.join(", ") + )); + } + Err(reason) +} + +/// `disable_setup_helper` — the Launcher tab's one-click fix for an enabled +/// transient HOME holder (Setup Wraith) that is live while stock is already +/// disabled. Same shape as the takeover's own step: the do-not-disable gate, +/// the last-Home-handler check, a verification that Home still resolves, and a +/// re-enable if it doesn't. +#[tauri::command] +pub async fn disable_setup_helper( + state: State<'_, AppState>, + serial: String, + package: String, +) -> Result { + state.require_pro(Feature::LauncherTakeover)?; + let result = disable_setup_helper_impl(state.inner(), &serial, &package).await; + match &result { + Ok(r) => tracing::info!(%serial, %package, ok = r.ok, "disable setup helper finished"), + Err(e) => { + tracing::info!(%serial, %package, ok = false, error = %e, "disable setup helper finished") + } + } + result +} + +pub async fn disable_setup_helper_impl( + state: &AppState, + serial: &str, + package: &str, +) -> Result { + let refuse = |message: String| Ok(crate::commands::apps::ActionResult { ok: false, message }); + if !is_valid_package_name(package) || !launchers().is_transient_home_holder(package) { + return refuse(format!("{package} is not a setup helper this app knows.")); + } + if matches!( + crate::engine::classify_safety(package), + crate::engine::Safety::NeverDisable { .. } + ) { + return refuse(format!("{package} is on the do-not-disable list.")); + } + let adb = state.adb_snapshot().await; + let enabled = match adb.shell(serial, HOME_HANDLER_QUERY).await { + Ok(out) if out.success() && !out.shell_reported_failure() => { + parse_home_handler_packages(&out.stdout) + } + _ => { + return refuse( + "Couldn't read this TV's Home apps, so turning the setup helper off can't be \ + proven safe. Nothing was changed." + .to_string(), + ) + } + }; + if is_last_enabled_home_handler(package, &enabled, launchers()) { + return refuse(format!( + "Refusing to disable {package}: no real launcher is enabled, so it would leave the \ + TV without a Home app. Re-enable a launcher first." + )); + } + let result = adb + .shell(serial, &format!("pm disable-user --user 0 {package}")) + .await; + let failed = command_failure(&result); + let _ = adb + .shell( + serial, + "am start -W -a android.intent.action.MAIN -c android.intent.category.HOME", + ) + .await; + let home_ok = failed.is_none() + && read_current_home(&*adb, serial) + .await + .ok() + .and_then(|r| r.package) + .is_some_and(|p| p != package); + if home_ok { + return Ok(crate::commands::apps::ActionResult { + ok: true, + message: "Setup Wraith is off.".to_string(), + }); + } + let restore = adb.shell(serial, &format!("pm enable {package}")).await; + if let Some(f) = command_failure(&restore) { + return refuse(format!( + "Couldn't confirm {package} is back on ({f}). Setup Wraith may still be off. Use \ + Re-enable Setup Wraith or Emergency Recovery." + )); + } + refuse(match failed { + Some(f) => format!("Couldn't disable {package}: {f}. It was left enabled."), + None => format!( + "Home didn't stay on a launcher after {package} was disabled, so it was re-enabled." + ), + }) +} + /// Disable the active *stock* launcher so HOME resolves to `package`. On builds /// where an enabled stock launcher overrides set-home-activity / the role API /// (they answer "Success" but HOME keeps resolving to stock — verified live on @@ -1085,6 +1321,18 @@ async fn stock_takeover( diagnostics: std::mem::take(diagnostics), }); } + let inventory = if launchers().disable_with_for(active).is_empty() { + Some(Vec::new()) + } else { + match adb.shell(serial, HOME_HANDLER_QUERY).await { + // A device with a stock launcher always has Home handlers, so an + // empty list is an unreadable answer, not an inventory. + Ok(out) if out.success() && !out.shell_reported_failure() => { + Some(parse_home_handler_packages(&out.stdout)).filter(|h| !h.is_empty()) + } + _ => None, + } + }; progress.step(&format!( "Disabling the stock launcher ({active}) to hand Home over" )); @@ -1106,15 +1354,31 @@ async fn stock_takeover( .await; progress.step("Checking whether Home switched over"); let verification = verify_active(adb, serial, package, diagnostics).await; + let mut paired_failure = None; if verification.confirmed { - return Some(SetLauncherResult { - ok: true, - strategy: Some("disable_stock_takeover".into()), - current_launcher: Some(package.to_string()), - last_error: None, - stock_takeover_available: false, - diagnostics: std::mem::take(diagnostics), - }); + match disable_paired_holders( + adb, + serial, + package, + &[active.to_string()], + inventory.as_deref(), + progress, + diagnostics, + ) + .await + { + Ok(()) => { + return Some(SetLauncherResult { + ok: true, + strategy: Some("disable_stock_takeover".into()), + current_launcher: Some(package.to_string()), + last_error: None, + stock_takeover_available: false, + diagnostics: std::mem::take(diagnostics), + }); + } + Err(reason) => paired_failure = Some(reason), + } } // Stock only gets re-enabled below when it *positively* still holds // HOME at the end of the window, or when the resolver never answered @@ -1126,7 +1390,9 @@ async fn stock_takeover( // from landing, so restoring it would only undo real progress // (GitHub #122: the onn 4K Pro reported this as a failure and rolled // stock back even though the switch had actually worked). - if let Some(holder) = verification + if let Some(reason) = paired_failure { + reason + } else if let Some(holder) = verification .last_active .as_deref() .filter(|holder| *holder != active) @@ -1149,10 +1415,11 @@ async fn stock_takeover( stock_takeover_available: false, diagnostics: std::mem::take(diagnostics), }); + } else { + format!( + "Takeover verification failed: Android did not report {package} as the Home app after the stock-disable command completed for {active}" + ) } - format!( - "Takeover verification failed: Android did not report {package} as the Home app after the stock-disable command completed for {active}" - ) }; // A transport or shell error cannot prove that the disable had no effect. @@ -2366,6 +2633,138 @@ mod tests { mod launcher_verification_122 { use super::*; + const GTV_STOCK: &str = "com.google.android.apps.tv.launcherx"; + const WRAITH: &str = "com.google.android.tungsten.setupwraith"; + + #[tokio::test] + async fn unreadable_home_apps_never_leave_stock_off_with_the_helper_on() { + let mock = MockAdb::default() + .on_shell("add-role-holder", "Unknown command") + .on_shell_failure("set-home-activity", "Error: no such activity") + .on_shell_err("query-activities", "device offline") + .on_shell_seq( + "resolve-activity", + &[ + &format!("{GTV_STOCK}/.Home"), + &format!("{GTV_STOCK}/.Home"), + "com.example.launcher/.MainActivity", + ], + ); + let log = mock.shell_log(); + let state = state_with(mock); + + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + true, + &Progress::Silent, + ) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + let stock_off = calls + .iter() + .any(|c| c == &format!("pm disable-user --user 0 {GTV_STOCK}")); + let stock_back = calls.iter().any(|c| c == &format!("pm enable {GTV_STOCK}")); + assert!( + !res.ok && (!stock_off || stock_back), + "stock off with the helper unchecked: {calls:?} {:?}", + res.last_error + ); + } + + #[tokio::test] + async fn an_empty_home_inventory_never_leaves_stock_off_with_the_helper_on() { + let mock = MockAdb::default() + .on_shell("add-role-holder", "Unknown command") + .on_shell_failure("set-home-activity", "Error: no such activity") + .on_shell("query-activities", "") + .on_shell_seq( + "resolve-activity", + &[ + &format!("{GTV_STOCK}/.Home"), + &format!("{GTV_STOCK}/.Home"), + "com.example.launcher/.MainActivity", + ], + ); + let log = mock.shell_log(); + let state = state_with(mock); + + let res = set_default_launcher_impl( + &state, + "serial", + "com.example.launcher", + true, + &Progress::Silent, + ) + .await + .unwrap(); + + let calls = log.lock().unwrap(); + let stock_off = calls + .iter() + .any(|c| c == &format!("pm disable-user --user 0 {GTV_STOCK}")); + let stock_back = calls.iter().any(|c| c == &format!("pm enable {GTV_STOCK}")); + assert!( + !res.ok && (!stock_off || stock_back), + "stock off with the helper unchecked: {calls:?} {:?}", + res.last_error + ); + } + + #[tokio::test] + async fn a_failed_stock_restore_after_a_helper_error_is_reported() { + let query = format!( + " packageName={GTV_STOCK}\n packageName={WRAITH}\n packageName=com.example.launcher\n" + ); + let mock = MockAdb::default() + .on_shell("query-activities", &query) + .on_shell("resolve-activity", "com.example.launcher/.MainActivity") + .on_shell_failure( + "disable-user --user 0 com.google.android.tungsten", + "Failure: no", + ) + .on_shell_failure(&format!("pm enable {GTV_STOCK}"), "Failure: no"); + let state = state_with(mock); + + let res = disable_stock_launcher_impl( + &state, + "serial", + "com.example.launcher", + &Progress::Silent, + ) + .await + .unwrap(); + + let error = res.last_error.unwrap_or_default(); + assert!(!res.ok); + assert!(error.contains("may still be off"), "{error}"); + assert!(!error.contains("was re-enabled"), "{error}"); + } + + #[tokio::test] + async fn a_failed_helper_rollback_is_reported_not_called_re_enabled() { + let query = format!( + " priority=0\n packageName={WRAITH}\n priority=0\n packageName=com.example.launcher\n" + ); + let mock = MockAdb::default() + .on_shell("query-activities", &query) + .on_shell("get-role-holders", "") + .on_shell("resolve-activity", &format!("{WRAITH}/.Wraith")) + .on_shell_failure("pm enable", "Failure: not allowed"); + let state = state_with(mock); + + let res = disable_setup_helper_impl(&state, "serial", WRAITH) + .await + .unwrap(); + + assert!(!res.ok); + assert!(res.message.contains("may still be off"), "{}", res.message); + assert!(!res.message.contains("was re-enabled"), "{}", res.message); + } + #[tokio::test] async fn resolver_lag_then_target_confirms_without_rollback() { // The resolver can take a few polls to catch up after a diff --git a/v2/crates/core/src/commands/loader.rs b/v2/crates/core/src/commands/loader.rs index c5417ef..9a8df23 100644 --- a/v2/crates/core/src/commands/loader.rs +++ b/v2/crates/core/src/commands/loader.rs @@ -403,6 +403,18 @@ mod tests { Some("Google TV setup helper — not a launcher") ); assert!(cat.is_transient_home_holder("com.google.android.tungsten.setupwraith")); + // #122: only Google TV Home pairs with Setup Wraith; Shield stock pairs with nothing. + assert_eq!( + cat.disable_with_for("com.google.android.apps.tv.launcherx"), + ["com.google.android.tungsten.setupwraith".to_string()] + ); + for entry in cat.stock.iter().filter(|e| !e.disable_with.is_empty()) { + assert_eq!(entry.package, "com.google.android.apps.tv.launcherx"); + assert!(entry + .disable_with + .iter() + .all(|p| cat.is_transient_home_holder(p))); + } } /// A custom launcher with no source is a row whose "Get" link cannot be diff --git a/v2/crates/core/src/engine/diagnostics.rs b/v2/crates/core/src/engine/diagnostics.rs index fb5215d..11b0ceb 100644 --- a/v2/crates/core/src/engine/diagnostics.rs +++ b/v2/crates/core/src/engine/diagnostics.rs @@ -44,6 +44,11 @@ pub struct DeviceDiagnostics<'a> { /// The current Home app as the Launcher tab reads it (`None` when it /// could not be read), with the role/resolver disagreement note if any. pub current_home: Option<&'a super::launcher::HomeReading>, + /// Catalogued setup helpers (Setup Wraith) installed on the device, as + /// `(package, enabled)`. `None` when the package state could not be read. + /// A disabled one never answers the HOME query, so this is read from the + /// package lists. + pub setup_helpers: Option<&'a [(String, bool)]>, } fn label(evidence: TvEvidence) -> &'static str { @@ -162,6 +167,18 @@ pub fn format_diagnostics(input: &DiagnosticsInput) -> String { if let Some(note) = device.current_home.and_then(|h| h.note.as_deref()) { out.push_str(&format!("- Note: {note}\n")); } + match device.setup_helpers { + None => out.push_str("- Setup helper: unknown (package state unreadable)\n"), + Some([]) => out.push_str("- Setup helper: not installed\n"), + Some(helpers) => { + for (package, enabled) in helpers { + out.push_str(&format!( + "- Setup helper `{package}`: {}\n", + if *enabled { "enabled" } else { "disabled" } + )); + } + } + } out.push_str("\n#### HOME handlers\n\n"); if device.home_handlers.is_empty() { @@ -247,6 +264,34 @@ mod tests { /// The whole point of #120's bundle: the two signals that decide whether /// the tools open have to be in what the user pastes, verbatim. + #[test] + fn setup_helper_state_says_not_installed_or_unknown_without_guessing() { + let render = |helpers: Option<&[(String, bool)]>| { + format_diagnostics(&DiagnosticsInput { + app_version: "2.3.0", + os: "linux", + arch: "x86_64", + adb_path: None, + adb_version: None, + device: Some(DeviceDiagnostics { + serial: "s", + connection: ConnectionType::Usb, + properties: None, + tv_evidence: TvEvidence::Unknown, + device_type: DeviceType::Unknown, + home_handlers: &[], + current_home: None, + setup_helpers: helpers, + }), + unreadable_device: None, + log_tail: &[], + }) + }; + assert!(render(Some(&[])).contains("- Setup helper: not installed")); + assert!(render(None).contains("- Setup helper: unknown")); + assert!(render(Some(&[("a.b.c".to_string(), true)])).contains("`a.b.c`: enabled")); + } + #[test] fn the_device_section_carries_both_tv_signals_and_the_verdict() { let props = props(); @@ -269,11 +314,19 @@ mod tests { activity: None, note: Some("resolve-activity HOME named a setup helper".to_string()), }), + setup_helpers: Some(&[( + "com.google.android.tungsten.setupwraith".to_string(), + false, + )]), }), unreadable_device: None, log_tail: &["first".to_string(), "second".to_string()], }); + assert!( + report.contains("- Setup helper `com.google.android.tungsten.setupwraith`: disabled"), + "{report}" + ); assert!( report.contains("- ro.build.characteristics: `nosdcard`"), "{report}" @@ -318,6 +371,7 @@ mod tests { device_type: DeviceType::Unknown, home_handlers: &[], current_home: None, + setup_helpers: None, }), unreadable_device: None, log_tail: &[], diff --git a/v2/crates/core/src/engine/launcher.rs b/v2/crates/core/src/engine/launcher.rs index 338d79c..6f9d0d0 100644 --- a/v2/crates/core/src/engine/launcher.rs +++ b/v2/crates/core/src/engine/launcher.rs @@ -17,6 +17,11 @@ pub struct LauncherEntry { /// HOME handlers discovered on the device rather than read from the file. #[serde(default)] pub source_url: Option, + /// Transient HOME holders (see `transient_home_holders`) that a takeover + /// disables together with this stock launcher, because with stock gone + /// they take the Home button back (#122). Stock entries only. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disable_with: Vec, } /// The launcher catalog, as loaded from `launchers.json`. @@ -54,6 +59,16 @@ impl LauncherCatalog { self.transient_home_holders.iter().any(|p| p == pkg) } + /// Transient holders to disable together with `stock_pkg`. Empty for a + /// package that isn't a stock launcher or has no pairing. + pub fn disable_with_for(&self, stock_pkg: &str) -> &[String] { + self.stock + .iter() + .find(|e| e.package == stock_pkg) + .map(|e| e.disable_with.as_slice()) + .unwrap_or(&[]) + } + /// True when `pkg` appears in either catalog list. pub fn contains(&self, pkg: &str) -> bool { self.is_stock(pkg) || self.custom.iter().any(|e| e.package == pkg) @@ -126,9 +141,27 @@ pub fn launcher_rows( }); let mut seen_other = std::collections::HashSet::new(); + // A disabled transient holder no longer answers the HOME query, and a + // takeover disables it without tracking it, so it is read from the package + // state instead: it keeps its row (and its re-enable path) while installed. + let transient_installed: Vec = catalog + .transient_home_holders + .iter() + .filter(|pkg| installed_pkgs.iter().any(|p| p == *pkg)) + // Only on Google TV, where a paired stock launcher is installed: a + // Shield ships the package disabled and has no use for the row. + .filter(|pkg| { + catalog.stock.iter().any(|e| { + e.disable_with.iter().any(|h| h == *pkg) + && installed_pkgs.iter().any(|p| p == &e.package) + }) + }) + .cloned() + .collect(); let other = home_handler_pkgs .iter() .chain(tracked_disabled_pkgs.iter()) + .chain(transient_installed.iter()) .filter(|pkg| { !catalog.contains(pkg) && !safe_home_handlers().contains(&pkg.as_str()) @@ -139,6 +172,7 @@ pub fn launcher_rows( name: catalog.home_handler_name(pkg).unwrap_or(pkg).to_string(), package: pkg.clone(), source_url: None, + disable_with: Vec::new(), }, installed: true, enabled: !is_disabled(pkg), @@ -150,6 +184,27 @@ pub fn launcher_rows( stock.chain(custom).chain(other).collect() } +/// The transient HOME holders to disable together with `stocks`, deduplicated +/// and limited to the ones the catalog knows as transient and `enabled` lists. +pub fn paired_transient_holders( + catalog: &LauncherCatalog, + stocks: &[String], + enabled: &[String], +) -> Vec { + let mut out: Vec = Vec::new(); + for stock in stocks { + for holder in catalog.disable_with_for(stock) { + if catalog.is_transient_home_holder(holder) + && enabled.iter().any(|e| e == holder) + && !out.contains(holder) + { + out.push(holder.clone()); + } + } + } + out +} + /// True when disabling `target` would leave the device without a single /// enabled HOME handler the user can actually land on. Safe fallbacks /// (Settings) don't count — they're a recovery hatch, not a launcher — and @@ -287,6 +342,7 @@ mod tests { name: name.to_string(), package: package.to_string(), source_url: source_url.map(str::to_string), + disable_with: Vec::new(), }; LauncherCatalog { custom: vec![ @@ -578,6 +634,107 @@ mod tests { .all(|r| !r.setup_helper)); } + const GTV_HOME: &str = "com.google.android.apps.tv.launcherx"; + + fn google_tv_catalog() -> LauncherCatalog { + let mut cat = catalog(); + cat.stock.push(LauncherEntry { + name: "Google TV Home (Stock)".to_string(), + package: GTV_HOME.to_string(), + source_url: None, + disable_with: vec![WRAITH.to_string()], + }); + cat + } + + #[test] + fn paired_holders_come_only_from_the_stock_being_disabled() { + let cat = google_tv_catalog(); + let enabled = pkgs(&[MONET, GTV_HOME, WRAITH]); + assert_eq!( + paired_transient_holders(&cat, &pkgs(&[GTV_HOME]), &enabled), + pkgs(&[WRAITH]) + ); + // Shield stock pairs with nothing, even with Setup Wraith enabled. + assert!(paired_transient_holders( + &cat, + &pkgs(&["com.google.android.tvlauncher"]), + &enabled + ) + .is_empty()); + // Already disabled (absent from the enabled list): nothing to do. + assert!( + paired_transient_holders(&cat, &pkgs(&[GTV_HOME]), &pkgs(&[MONET, GTV_HOME])) + .is_empty() + ); + // Two stocks naming one holder disable it once. + assert_eq!( + paired_transient_holders(&cat, &pkgs(&[GTV_HOME, GTV_HOME]), &enabled), + pkgs(&[WRAITH]) + ); + } + + #[test] + fn a_pairing_must_name_a_catalogued_transient_holder() { + let mut cat = google_tv_catalog(); + cat.stock.last_mut().unwrap().disable_with = pkgs(&["com.android.tv.settings"]); + assert!(paired_transient_holders( + &cat, + &pkgs(&[GTV_HOME]), + &pkgs(&["com.android.tv.settings"]) + ) + .is_empty()); + } + + #[test] + fn wraith_alone_is_not_a_home_so_it_is_never_the_survivor() { + // Stock gone, Wraith the only other handler: disabling Wraith would + // leave nothing, and stock disabling would leave only Wraith. + let cat = google_tv_catalog(); + assert!(is_last_enabled_home_handler( + GTV_HOME, + &pkgs(&[GTV_HOME, WRAITH]), + &cat + )); + assert!(is_last_enabled_home_handler(WRAITH, &pkgs(&[WRAITH]), &cat)); + } + + #[test] + fn disabled_setup_wraith_keeps_its_row_without_being_tracked() { + // A disabled package no longer answers the HOME query and a takeover + // does not track it, so the package state alone has to surface it. + let rows = launcher_rows( + &google_tv_catalog(), + &pkgs(&[MONET, GTV_HOME, WRAITH]), + &pkgs(&[WRAITH]), + &[], + &[], + ); + let row = rows.iter().find(|r| r.entry.package == WRAITH).unwrap(); + assert!(row.installed && !row.enabled && row.setup_helper && row.other); + // Installed but disabled with no Google TV stock (a Shield): no row. + let rows = launcher_rows( + &google_tv_catalog(), + &pkgs(&[MONET, WRAITH]), + &pkgs(&[WRAITH]), + &[], + &[], + ); + assert!(!rows.iter().any(|r| r.entry.package == WRAITH)); + // Not installed: no row at all. + let rows = launcher_rows(&catalog(), &pkgs(&[MONET]), &[], &[], &[]); + assert!(!rows.iter().any(|r| r.entry.package == WRAITH)); + // Enabled and answering the query: still one row. + let rows = launcher_rows( + &catalog(), + &pkgs(&[MONET, WRAITH]), + &[], + &pkgs(&[WRAITH]), + &[], + ); + assert_eq!(rows.iter().filter(|r| r.entry.package == WRAITH).count(), 1); + } + #[test] fn package_name_validation_accepts_valid() { for valid in &[ diff --git a/v2/crates/core/src/engine/mod.rs b/v2/crates/core/src/engine/mod.rs index 305522c..7263a39 100644 --- a/v2/crates/core/src/engine/mod.rs +++ b/v2/crates/core/src/engine/mod.rs @@ -19,8 +19,8 @@ pub use app_lists::{AppList, AppListBundle}; pub use detection::{detect_device_type, tv_evidence, DeviceType, TvEvidence}; pub use diagnostics::{format_diagnostics, DeviceDiagnostics, DiagnosticsInput}; pub use launcher::{ - is_last_enabled_home_handler, is_valid_package_name, launcher_rows, pick_current_home, - HomeReading, LauncherCatalog, LauncherEntry, LauncherStatus, + is_last_enabled_home_handler, is_valid_package_name, launcher_rows, paired_transient_holders, + pick_current_home, HomeReading, LauncherCatalog, LauncherEntry, LauncherStatus, }; pub use media::{ build_capabilities, parse_media_codecs, surround_mode, video_formats, AudioPassthrough, diff --git a/v2/e2e/scenarios/42-setup-wraith.mjs b/v2/e2e/scenarios/42-setup-wraith.mjs new file mode 100644 index 0000000..e2b18c7 --- /dev/null +++ b/v2/e2e/scenarios/42-setup-wraith.mjs @@ -0,0 +1,226 @@ +// Google TV's Setup Wraith (#122): it declares HOME with a high filter +// priority, so once the stock launcher is disabled it takes the Home button +// back unless the takeover turns it off too. The simulator's +// `stock_then_priority` policy models the reporter's onn 4K Pro. + +import { SHIELD, shieldScenario, stockHomeShield } from "../lib/worlds.mjs"; + +const PROJECTIVY = "com.spocky.projengmenu"; +const GTV_HOME = "com.google.android.apps.tv.launcherx"; +const WRAITH = "com.google.android.tungsten.setupwraith"; +const SDK31 = { "ro.build.version.sdk": "31" }; + +function googleTv(extra = {}) { + const { setup = [], ...rest } = extra; + return shieldScenario({ + props: SDK31, + home_policy: "stock_then_priority", + home_apps: [ + { package: GTV_HOME, class: `${GTV_HOME}.home.HomeActivity`, priority: 0 }, + { package: WRAITH, class: `${WRAITH}.ui.MainActivity`, priority: 3 }, + { package: PROJECTIVY, class: `${PROJECTIVY}.ui.home.MainActivity`, priority: 0 }, + ], + setup, + ...rest, + }); +} + +const HOME_ROLE = `cmd role add-role-holder android.app.role.HOME ${PROJECTIVY}`; + +async function device(ctx) { + return ctx.device(SHIELD.serial); +} + +async function assertKeepsHome(ctx) { + const h = (await device(ctx)).home; + ctx.assert.ok( + h.handlers.some((c) => !c.includes("FallbackHome")), + `a real Home app is still enabled (handlers: ${h.handlers.join(", ")})`, + ); +} + +function callout(ctx) { + return ctx.page.locator(".setup-helper-callout"); +} + +async function takeOver(ctx) { + const set = await ctx.invoke("set_home_any", { serial: SHIELD.key, package: PROJECTIVY, activity: null }); + ctx.assert.equal(set.value.ok, true, "Projectivy accepted as Home"); + return ctx.invoke("disable_stock_launcher", { serial: SHIELD.key, target: PROJECTIVY }); +} + +export const scenarios = [ + { + name: "setup-wraith-takeover-disables-it-and-recovery-restores-both", + async run(ctx) { + await ctx.reset(googleTv()); + const before = await device(ctx); + ctx.assert.ok(before.packages.enabled.includes(WRAITH), "Setup Wraith starts enabled"); + ctx.assert.ok(before.packages.enabled.includes(GTV_HOME), "stock starts enabled"); + + const res = await takeOver(ctx); + ctx.assert.equal(res.value.ok, true, JSON.stringify(res.value.diagnostics)); + const d = await device(ctx); + ctx.assert.ok(d.packages.disabled.includes(GTV_HOME), "stock disabled"); + ctx.assert.ok(d.packages.disabled.includes(WRAITH), "Setup Wraith disabled with it"); + ctx.assert.match(d.home.resolved, new RegExp(PROJECTIVY), "Projectivy keeps Home"); + ctx.assert.equal(d.home.role_holder, PROJECTIVY); + await assertKeepsHome(ctx); + + await ctx.openDevice(SHIELD.key, "launcher"); + await ctx.page.locator("ul.launcher-list").getByText("Google TV setup helper").waitFor(); + await callout(ctx).waitFor(); + ctx.assert.equal(await callout(ctx).getAttribute("data-setup-helper"), "off"); + await ctx.step("takeover left Setup Wraith off, with its way back"); + + await callout(ctx).getByRole("button", { name: "Re-enable Setup Wraith" }).click(); + await ctx.waitFor(async () => (await device(ctx)).packages.enabled.includes(WRAITH), { message: "Setup Wraith re-enabled" }); + await ctx.waitFor(async () => (await callout(ctx).getAttribute("data-setup-helper")) === "risk", { message: "callout warns it is on with stock off" }); + await ctx.step("Re-enable Setup Wraith works"); + + // Emergency Recovery re-enables everything disabled, stock and helper alike. + await ctx.invoke("disable_setup_helper", { serial: SHIELD.key, package: WRAITH }); + const rec = await ctx.invoke("panic_recovery", { serial: SHIELD.key }); + ctx.assert.ok(rec.value.restored.includes(GTV_HOME), "recovery restored stock"); + const after = await device(ctx); + ctx.assert.ok(after.packages.enabled.includes(GTV_HOME), "stock enabled again"); + ctx.assert.ok(after.packages.enabled.includes(WRAITH), "Setup Wraith enabled again"); + await assertKeepsHome(ctx); + }, + }, + { + name: "setup-wraith-re-enabling-stock-re-enables-the-helper", + async run(ctx) { + await ctx.reset(googleTv()); + const res = await takeOver(ctx); + ctx.assert.equal(res.value.ok, true, JSON.stringify(res.value.diagnostics)); + await ctx.openDevice(SHIELD.key, "launcher"); + await callout(ctx).waitFor(); + const stockRow = ctx.page.locator("ul.launcher-list li", { hasText: "Google TV Home (Stock)" }); + await stockRow.getByRole("button", { name: "Enable", exact: true }).click(); + await ctx.waitFor( + async () => { + const p = (await device(ctx)).packages.enabled; + return p.includes(GTV_HOME) && p.includes(WRAITH); + }, + { message: "stock and Setup Wraith both re-enabled" }, + ); + await assertKeepsHome(ctx); + }, + }, + { + name: "setup-wraith-takeover-failure-restores-stock-and-the-helper", + async run(ctx) { + await ctx.reset({ + ...googleTv(), + faults: [ + { matches: `pm disable-user --user 0 ${WRAITH}`, effect: { type: "fail", stderr: "java.lang.SecurityException: Permission denial\n", exit_code: 255 } }, + ], + }); + const res = await takeOver(ctx); + ctx.assert.equal(res.value.ok, false, "the takeover reports the failure"); + const d = await device(ctx); + ctx.assert.ok(d.packages.enabled.includes(GTV_HOME), "stock re-enabled"); + ctx.assert.ok(d.packages.enabled.includes(WRAITH), "Setup Wraith re-enabled"); + await assertKeepsHome(ctx); + }, + }, + { + name: "setup-wraith-callout-states", + async run(ctx) { + // Enabled, stock still on: heads-up pointing at the takeover. + await ctx.reset(googleTv()); + await ctx.openDevice(SHIELD.key, "launcher"); + await callout(ctx).waitFor(); + ctx.assert.equal(await callout(ctx).getAttribute("data-setup-helper"), "on"); + ctx.assert.match(await callout(ctx).innerText(), /Disable stock launcher turns it off too/); + ctx.assert.equal(await callout(ctx).getByRole("button").count(), 0, "no action while stock is on"); + await ctx.step("enabled while stock is on"); + + // Enabled while stock is off and nothing real holds Home: it grabs Home. + await ctx.reset(googleTv({ setup: [`pm disable-user --user 0 ${GTV_HOME}`] })); + await ctx.openDevice(SHIELD.key, "launcher"); + await callout(ctx).waitFor(); + ctx.assert.equal(await callout(ctx).getAttribute("data-setup-helper"), "risk"); + ctx.assert.match(await callout(ctx).innerText(), /will likely grab the Home button/); + ctx.assert.match((await device(ctx)).home.resolved, new RegExp(WRAITH), "the resolver names Setup Wraith"); + await ctx.step("enabled while stock is off"); + + await callout(ctx).getByRole("button", { name: "Turn it off" }).click(); + await ctx.waitFor(async () => (await device(ctx)).packages.disabled.includes(WRAITH), { message: "Setup Wraith disabled" }); + await ctx.waitFor(async () => (await callout(ctx).getAttribute("data-setup-helper")) === "off", { message: "callout says it is off" }); + ctx.assert.match((await device(ctx)).home.resolved, new RegExp(PROJECTIVY), "Home lands on Projectivy"); + ctx.assert.match(await callout(ctx).innerText(), /sign in again or you\s+need to pair a remote/); + await ctx.step("one-click fix turned it off"); + await assertKeepsHome(ctx); + + // Disabled: the way back is on the Launcher tab. + await callout(ctx).getByRole("button", { name: "Re-enable Setup Wraith" }).waitFor(); + }, + }, + { + name: "setup-wraith-one-click-fix-refuses-without-a-real-launcher", + async run(ctx) { + await ctx.reset( + googleTv({ + setup: [`pm disable-user --user 0 ${GTV_HOME}`, `pm disable-user --user 0 ${PROJECTIVY}`], + }), + ); + const r = await ctx.invoke("disable_setup_helper", { serial: SHIELD.key, package: WRAITH }); + ctx.assert.equal(r.value.ok, false); + ctx.assert.ok((await device(ctx)).packages.enabled.includes(WRAITH), "left enabled"); + const notHelper = await ctx.invoke("disable_setup_helper", { serial: SHIELD.key, package: PROJECTIVY }); + ctx.assert.equal(notHelper.value.ok, false, "only a catalogued setup helper is accepted"); + await assertKeepsHome(ctx); + }, + }, + { + name: "setup-wraith-confirm-names-both-effects", + async run(ctx) { + // The role readback is unavailable, so Android's answer leaves stock in + // charge and the picker offers the takeover. + await ctx.reset({ + ...googleTv(), + faults: [{ matches: "get-role-holders", effect: { type: "fail", stderr: "Error\n", exit_code: 255 } }], + }); + await ctx.openDevice(SHIELD.key, "launcher"); + await ctx.page.getByText("Advanced: set another app as Home…").click(); + const select = ctx.page.getByLabel("App to set as Home"); + await ctx.waitFor(async () => (await select.locator(`option[value="${PROJECTIVY}"]`).count()) > 0, { message: "picker lists Projectivy" }); + await select.selectOption(PROJECTIVY); + await ctx.page.getByRole("button", { name: "Set as Home" }).click(); + await ctx.page.locator(".home-picker-result").waitFor({ timeout: 30_000 }); + await ctx.page.locator(".home-picker-row").getByRole("button", { name: "Disable stock launcher" }).click(); + const confirm = ctx.page.locator(".home-picker-confirm"); + await confirm.waitFor(); + const text = await confirm.innerText(); + ctx.assert.match(text, /Also turns off Google TV's setup helper \(Setup Wraith\)/); + ctx.assert.match(text, /turn it back on briefly to sign in to Google again or\s+pair a remote/); + ctx.assert.match(text, /Save snapshot first/, "snapshot-first is still offered"); + await ctx.step("confirm names both effects"); + }, + }, + { + name: "setup-wraith-shield-is-unchanged", + async run(ctx) { + // The captured Shield ships Setup Wraith installed but disabled; it must + // get no row and no callout. + await ctx.reset(stockHomeShield()); + await ctx.openDevice(SHIELD.key, "launcher"); + await ctx.page.locator("ul.launcher-list").getByText("Projectivy Launcher").waitFor(); + ctx.assert.equal(await callout(ctx).count(), 0, "no setup-helper callout on a Shield"); + const res = await ctx.invoke("set_home_any", { serial: SHIELD.key, package: PROJECTIVY, activity: null }); + ctx.assert.equal(res.value.ok, false, "stock still holds Home"); + const out = await ctx.invoke("disable_stock_launcher", { serial: SHIELD.key, target: PROJECTIVY }); + ctx.assert.equal(out.value.ok, true, JSON.stringify(out.value.diagnostics)); + const d = await device(ctx); + ctx.assert.ok(d.packages.disabled.includes("com.google.android.tvlauncher"), "stock disabled"); + ctx.assert.match(d.home.resolved, new RegExp(PROJECTIVY)); + const log = (await ctx.log()).map((i) => i.args.join(" ")).join("\n"); + ctx.assert.ok(!log.includes("setupwraith"), "no command named the setup helper"); + await ctx.openDevice(SHIELD.key, "launcher"); + ctx.assert.equal(await callout(ctx).count(), 0, "still no callout"); + await assertKeepsHome(ctx); + }, + }, +]; diff --git a/v2/mobile/src/lib/types.ts b/v2/mobile/src/lib/types.ts index 7bc76d0..883423c 100644 --- a/v2/mobile/src/lib/types.ts +++ b/v2/mobile/src/lib/types.ts @@ -198,6 +198,7 @@ export type Entitlement = "free" | "pro"; export interface LauncherEntry { name: string; package: string; + disable_with?: string[]; } export interface LauncherStatus { diff --git a/v2/src-tauri/src/commands/diagnostics.rs b/v2/src-tauri/src/commands/diagnostics.rs index 0854e52..a778da5 100644 --- a/v2/src-tauri/src/commands/diagnostics.rs +++ b/v2/src-tauri/src/commands/diagnostics.rs @@ -268,6 +268,40 @@ fn home_handler_components(stdout: &str) -> Vec { launcher::parse_home_handler_packages(stdout) } +/// `(package, enabled)` for each catalogued setup helper that is installed. +/// Read from the package lists because a disabled one never answers the HOME +/// query; `None` when they could not be read. +async fn setup_helper_states( + adb: &dyn shield_optimizer_core::adb::AdbDriver, + serial: &str, +) -> Option> { + let cmd = shield_optimizer_core::adb::checked_batch_command(&[ + "pm list packages", + "pm list packages -d", + ]); + let out = adb.shell(serial, &cmd).await.ok()?; + let sections = shield_optimizer_core::adb::parse_checked_batch(&out.stdout, 2, &[0, 1]).ok()?; + setup_helper_states_from(§ions[0], §ions[1]) +} + +/// An Android device always has packages, so an empty installed list is an +/// unreadable answer, not proof the helper is absent. +fn setup_helper_states_from(installed: &str, disabled: &str) -> Option> { + let installed = shield_optimizer_core::adb::parse_installed_packages_output(installed); + if installed.is_empty() { + return None; + } + let disabled = shield_optimizer_core::adb::parse_disabled_packages_output(disabled); + Some( + shield_optimizer_core::commands::loader::launchers() + .transient_home_holders + .iter() + .filter(|p| installed.contains(p)) + .map(|p| (p.clone(), !disabled.contains(p))) + .collect(), + ) +} + /// `collect_diagnostics` — the text a user pastes into a bug report. /// /// Nothing is sent anywhere: this command returns a string and the UI puts it @@ -303,7 +337,8 @@ pub async fn collect_diagnostics( .map(|out| home_handler_components(&out.stdout)) .unwrap_or_default(); let current_home = launcher::read_current_home(&*adb, serial).await.ok(); - Some((device, handlers, current_home)) + let helpers = setup_helper_states(&*adb, serial).await; + Some((device, handlers, current_home, helpers)) } Err(e) => { tracing::warn!(serial, error = %e, "diagnostics: device unavailable"); @@ -323,15 +358,18 @@ pub async fn collect_diagnostics( adb_version: adb_version.as_deref(), device: device .as_ref() - .map(|(device, handlers, current_home)| DeviceDiagnostics { - serial: &device.serial, - connection: device.connection, - properties: device.properties.as_ref(), - tv_evidence: device.tv_evidence, - device_type: device.device_type, - home_handlers: handlers, - current_home: current_home.as_ref(), - }), + .map( + |(device, handlers, current_home, helpers)| DeviceDiagnostics { + serial: &device.serial, + connection: device.connection, + properties: device.properties.as_ref(), + tv_evidence: device.tv_evidence, + device_type: device.device_type, + home_handlers: handlers, + current_home: current_home.as_ref(), + setup_helpers: helpers.as_deref(), + }, + ), unreadable_device: match (serial.as_deref(), unreadable.as_deref()) { (Some(serial), Some(error)) => Some((serial, error)), _ => None, @@ -344,6 +382,14 @@ pub async fn collect_diagnostics( mod tests { use super::*; + #[test] + fn an_empty_installed_list_is_unknown_not_absent() { + assert_eq!(setup_helper_states_from("", ""), None); + assert_eq!(setup_helper_states_from("\n", "package:a\n"), None); + let some = setup_helper_states_from("package:com.android.settings\n", ""); + assert_eq!(some, Some(vec![])); + } + #[test] fn home_handlers_are_read_from_the_real_resolveinfo_shape() { // `name=` is the bare class (no slash) and `packageName=` is the diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 27454ac..d330b72 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -100,6 +100,7 @@ pub fn invoke_handler( launcher::disable_launcher, launcher::set_home_any, launcher::disable_stock_launcher, + launcher::disable_setup_helper, apps::disable_package, apps::enable_package, apps::force_stop, diff --git a/v2/src/lib/api.ts b/v2/src/lib/api.ts index 3564122..3e90993 100644 --- a/v2/src/lib/api.ts +++ b/v2/src/lib/api.ts @@ -114,6 +114,8 @@ export const api = { }), disableLauncher: (serial: string, pkg: string) => invoke("disable_launcher", { serial, package: pkg }), + disableSetupHelper: (serial: string, pkg: string) => + invoke("disable_setup_helper", { serial, package: pkg }), setHomeAny: (serial: string, pkg: string, activity: string | null = null) => invoke("set_home_any", { serial, package: pkg, activity }), disableStockLauncher: (serial: string, target: string) => diff --git a/v2/src/lib/demo-mock.ts b/v2/src/lib/demo-mock.ts index 30cb3c2..7a0d870 100644 --- a/v2/src/lib/demo-mock.ts +++ b/v2/src/lib/demo-mock.ts @@ -810,6 +810,8 @@ function handle(cmd: string, args: Record): unknown { diagnostics: [], }; } + case "disable_setup_helper": + return { ok: true, message: "Setup Wraith is off." }; case "disable_stock_launcher": return { ok: true, diff --git a/v2/src/lib/types.ts b/v2/src/lib/types.ts index 28841e4..2d5c995 100644 --- a/v2/src/lib/types.ts +++ b/v2/src/lib/types.ts @@ -86,6 +86,9 @@ export interface LauncherEntry { /// installed. Null for stock launchers and for HOME handlers found on the /// device rather than in the catalog. source_url: string | null; + /// Setup helpers (Setup Wraith) a takeover turns off together with this + /// stock launcher. Present only on a stock entry that has one. + disable_with?: string[]; } export interface LauncherStatus { diff --git a/v2/src/routes/devices/[serial]/+page.svelte b/v2/src/routes/devices/[serial]/+page.svelte index 31075ec..cf899e9 100644 --- a/v2/src/routes/devices/[serial]/+page.svelte +++ b/v2/src/routes/devices/[serial]/+page.svelte @@ -1388,10 +1388,13 @@ homePickerBusy = "stock"; homePickerMessage = ""; try { + const wraithNote = takeoverTurnsOffSetupHelper + ? " Google TV's setup helper (Setup Wraith) was turned off too." + : ""; const r = await api.disableStockLauncher(serial, pkg); homePickerOk = r.ok; homePickerMessage = r.ok - ? `The stock launcher is disabled and ${pkg} is Home. Re-enable stock from the list above any time.` + ? `The stock launcher is disabled and ${pkg} is Home.${wraithNote} Re-enable stock from the list above any time.` : (r.last_error ?? "The stock launcher was left alone."); launcherDiagnostics = r.ok ? [] : (r.diagnostics ?? []); stockHoldsHomeFor = null; @@ -1420,6 +1423,17 @@ launcherActionMessage = `Couldn't enable ${name}: ${r.message.trim() || "failed"}`; return; } + // Re-enabling a stock launcher undoes its takeover, which also turned + // off the setup helpers it is paired with (#122). + const pairedOff = (launchers.find((l) => l.entry.package === pkg && l.stock)?.entry.disable_with ?? []).filter( + (h) => launchers.some((l) => l.entry.package === h && l.installed && !l.enabled), + ); + const helperFailures: string[] = []; + for (const helper of pairedOff) { + launcherProgress = "Re-enabling Google TV's setup helper"; + const hr = await api.enablePackage(serial, helper); + if (!hr.ok) helperFailures.push(`${helper}: ${hr.message.trim() || "failed"}`); + } refreshMeasurements(); launcherProgress = "Refreshing the launcher list"; await loadLauncher(); @@ -1440,6 +1454,9 @@ } else { launcherActionMessage = `${name} enabled.`; } + if (helperFailures.length > 0) { + launcherActionMessage += ` Couldn't re-enable Google TV's setup helper (${helperFailures.join("; ")}). Use Re-enable Setup Wraith to retry.`; + } // A launcher's enabled state changed — the Memory tab's report is now stale. invalidateDeviceCaches(); } catch (e) { @@ -1450,6 +1467,55 @@ } } + /// Google TV's setup helper (Setup Wraith), whenever it is installed. A + /// disabled one no longer answers the HOME query, so the backend reads it + /// from the package lists and it stays a row either way. + const setupHelper = $derived(launchers.find((l) => l.setup_helper && l.installed) ?? null); + const stockEnabled = $derived(launchers.some((l) => l.stock && l.enabled)); + /// "off": disabled. "on": enabled, with something else holding or able to + /// hold Home. "risk": enabled while stock is off. The role can still name a + /// custom launcher while the helper's higher priority wins Home, so the + /// role-derived current launcher does not clear it. + const setupHelperState = $derived<"off" | "on" | "risk" | null>( + !setupHelper + ? null + : !setupHelper.enabled + ? "off" + : stockEnabled + ? "on" + : "risk", + ); + /// A takeover will also turn the setup helper off: it is enabled and an + /// enabled stock launcher names it in the catalog. + const takeoverTurnsOffSetupHelper = $derived( + !!setupHelper?.enabled && + launchers.some( + (l) => l.stock && l.enabled && (l.entry.disable_with ?? []).includes(setupHelper.entry.package), + ), + ); + + async function turnOffSetupHelper() { + const row = setupHelper; + if (!row) return; + const pkg = row.entry.package; + launcherActionBusy = pkg; + launcherActionMessage = ""; + launcherProgress = "Turning off Google TV's setup helper"; + try { + const r = await api.disableSetupHelper(serial, pkg); + launcherActionMessage = r.ok + ? "Setup Wraith is off. Press Home on the TV to check it lands on your launcher." + : `Couldn't turn off Setup Wraith: ${r.message.trim() || "failed"}`; + await loadLauncher(); + invalidateDeviceCaches(); + } catch (e) { + launcherActionMessage = String(e); + } finally { + launcherActionBusy = null; + launcherProgress = ""; + } + } + async function disableLauncher(pkg: string) { const row = launchers.find((l) => l.entry.package === pkg); const name = row?.entry.name ?? pkg; @@ -2621,6 +2687,43 @@ {/each} + {#if setupHelper && setupHelperState} +
+ + + {#if setupHelperState === "off"} + Setup Wraith is off. Turn this back on if Google asks you to sign in again or you + need to pair a remote; turn it off again after. + {:else if setupHelperState === "on"} + Google TV's setup helper (Setup Wraith) is on. It can take the Home button back + after you switch launchers. Disable stock launcher turns it off too. + {:else} + Google TV's setup helper (Setup Wraith) is on while the stock launcher is off. It + will likely grab the Home button. + {/if} + + {#if setupHelperState === "off"} + + {:else if setupHelperState === "risk"} + + {/if} +
+ {/if} @@ -2731,6 +2834,11 @@ Disable the stock launcher and hand Home to {homePickerChoice}? If Home doesn't land on it, the stock launcher is re-enabled straight away. You can re-enable it from the list above at any time. + {#if takeoverTurnsOffSetupHelper} + Also turns off Google TV's setup helper (Setup Wraith), or it takes the Home + button back. You may need to turn it back on briefly to sign in to Google again + or pair a remote. + {/if}