diff --git a/src/core/adb.rs b/src/core/adb.rs index 0e085c7..b281b01 100644 --- a/src/core/adb.rs +++ b/src/core/adb.rs @@ -45,11 +45,10 @@ use std::os::windows::process::CommandExt; use crate::core::utils::is_all_w_c; -pub fn to_trimmed_utf8(v: Vec) -> String { - String::from_utf8(v) - .expect("ADB should always output valid ASCII (or UTF-8, at least)") - .trim_end() - .to_string() +/// Convert ADB output bytes to a trimmed UTF-8 string. +/// Uses lossy conversion to prevent panics on non-UTF8 output from certain OEMs. +pub fn to_trimmed_utf8(v: &[u8]) -> String { + String::from_utf8_lossy(v).trim_end().to_string() } #[must_use] @@ -199,11 +198,11 @@ impl ACommand { Err("Cannot run ADB, likely not found".to_string()) } Ok(o) => { - let stdout = to_trimmed_utf8(o.stdout); + let stdout = to_trimmed_utf8(&o.stdout); if o.status.success() { Ok(stdout) } else { - let stderr = to_trimmed_utf8(o.stderr); + let stderr = to_trimmed_utf8(&o.stderr); // ADB does really weird things: // Some errors are not redirected to `stderr` let err = if stdout.is_empty() { stderr } else { stdout }; @@ -267,12 +266,12 @@ pub const fn is_pkg_component(s: &[u8]) -> bool { } /// String with the invariant of being a valid package-name. -/// See its `new` constructor for more info. +/// See [`PackageId::new`] for validation details. #[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash)] pub struct PackageId(Box); impl PackageId { /// Creates a package-ID if it's valid according to - /// [this](https://developer.android.com/build/configure-app-module#set-application-id) + /// pub fn new(p_id: Box) -> Option { let mut components = p_id.split('.'); for _ in 0..2 { @@ -323,8 +322,7 @@ const PACK_PREFIX: &str = "package:"; pub const PM_CLEAR_PACK: &str = "pm clear"; /// Builder object for an Android Package Manager command. -/// -/// [More info](https://developer.android.com/tools/adb#pm) +/// #[derive(Debug)] pub struct PmCommand(ShellCommand); impl PmCommand { @@ -391,8 +389,9 @@ impl PmCommand { }; let ln = ln.strip_suffix('}').unwrap_or(ln).trim_ascii_end(); // https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/core/java/android/content/pm/UserInfo.java - // the format seems to be stable across Android versions: - // "\tUserInfo{::}[ running]" + // The format looks stable today, but google may change it in future Android versions + // (and very old Androids might differ). Keep parsing defensive. + // Expected shape: "UserInfo{::}[ running]" let mut comps = ln.split(':'); @@ -422,8 +421,7 @@ impl PmCommand { } } -/// Mirror of AOSP `UserInfo` Java Class, -/// with an extra field +/// Mirror of AOSP `UserInfo` Java Class, with an extra field #[derive(Debug, Clone)] pub struct UserInfo { id: u16, @@ -437,8 +435,7 @@ impl UserInfo { self.id } /* - /// Check if the user was logged-in - /// at the time `pm list users` was invoked + /// Check if the user was logged-in at the time `pm list users` was invoked #[must_use] #[allow(dead_code, reason = "Currently unused by UI; kept for future features")] pub const fn was_running(&self) -> bool { diff --git a/src/core/save.rs b/src/core/save.rs index 84b1f5b..7be7084 100644 --- a/src/core/save.rs +++ b/src/core/save.rs @@ -2,6 +2,7 @@ use crate::core::config::{Config, DeviceSettings}; use crate::core::sync::{CorePackage, Phone, User, apply_pkg_state_commands}; use crate::core::utils::DisplayablePath; use crate::gui::widgets::package_row::PackageRow; +use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use std::{ fs, @@ -99,15 +100,22 @@ pub fn list_available_backup_user(backup: DisplayablePath) -> Vec { #[derive(Debug)] pub struct BackupPackage { + pub i_user: usize, pub index: usize, pub commands: Vec, } +#[derive(Debug)] +pub struct RestoreResult { + pub packages: Vec, + pub skipped_count: usize, +} + pub fn restore_backup( selected_device: &Phone, packages: &[Vec], settings: &DeviceSettings, -) -> Result, String> { +) -> Result { match fs::read_to_string( settings .backup @@ -122,24 +130,26 @@ pub fn restore_backup( serde_json::from_str(&data).expect("Unable to parse backup file"); let mut commands = vec![]; + let mut skipped_packages = 0; for u in phone_backup.users { - let index = match selected_device.user_list.iter().find(|x| x.id == u.id) { + let i_user = match selected_device.user_list.iter().find(|x| x.id == u.id) { Some(i) => i.index, None => return Err(format!("user {} doesn't exist", u.id)), }; for (i, backup_package) in u.packages.iter().enumerate() { - let package: CorePackage = match packages[index] + let package: CorePackage = if let Some(p) = packages[i_user] .iter() .find(|x| x.name == backup_package.name) { - Some(p) => p.into(), - None => { - return Err(format!( - "{} not found for user {}", - backup_package.name, u.id - )); - } + p.into() + } else { + skipped_packages += 1; + warn!( + "{} not found for user {} - skipping package during restore", + backup_package.name, u.id + ); + continue; }; let p_commands = apply_pkg_state_commands( &package, @@ -152,19 +162,29 @@ pub fn restore_backup( ); if !p_commands.is_empty() { commands.push(BackupPackage { + i_user, index: i, commands: p_commands, }); } } } + if skipped_packages > 0 { + info!( + "Restore completed with {skipped_packages} packages skipped (not found on device)" + ); + } if !commands.is_empty() { commands.push(BackupPackage { + i_user: 0, index: 0, commands: vec![], }); } - Ok(commands) + Ok(RestoreResult { + packages: commands, + skipped_count: skipped_packages, + }) } Err(e) => Err(e.to_string()), } diff --git a/src/core/sync.rs b/src/core/sync.rs index b663661..932c006 100644 --- a/src/core/sync.rs +++ b/src/core/sync.rs @@ -72,20 +72,74 @@ pub async fn run_adb_action>( match AdbCommand::new().shell(serial).raw(&action) { Ok(o) => { if ["Error", "Failure"].iter().any(|&e| o.contains(e)) { - return Err(AdbError::Generic(format!("[{label}] {action} -> {o}"))); + let friendly_msg = make_friendly_error_message(&o, &action); + return Err(AdbError::Generic(format!("[{label}] {friendly_msg}"))); } info!("[{label}] {action} -> {o}"); Ok(p) } Err(err) => { if !err.contains("[not installed for") { - return Err(AdbError::Generic(format!("[{label}] {action} -> {err}"))); + let friendly_msg = make_friendly_error_message(&err, &action); + return Err(AdbError::Generic(format!("[{label}] {friendly_msg}"))); } Err(AdbError::Generic(err)) } } } +/// Convert common OEM-specific ADB error messages into user-friendly explanations. +fn make_friendly_error_message(error_output: &str, action: &str) -> String { + // Common Samsung errors + if error_output.contains("DELETE_FAILED_USER_RESTRICTED") { + return format!( + "Cannot uninstall: This package is restricted by the device manufacturer (Samsung Knox or similar).\n\ + Error: {error_output}\n\ + Tip: Try disabling the package instead, or check device settings for Knox/security restrictions." + ); + } + + if error_output.contains("NOT_INSTALLED_FOR_USER") { + return format!( + "Package is not installed for the current user.\n\ + Error: {error_output}\n\ + Tip: The package may be installed for a different user profile or work profile." + ); + } + + // Empty package name error + if error_output.contains("Shell cannot change component state for null") { + return format!( + "Invalid package: Empty package name detected.\n\ + Error: {error_output}\n\ + Tip: Please refresh the package list and try again." + ); + } + + // Generic permission errors + if error_output.contains("Permission denied") + || error_output.contains("INSTALL_FAILED_PERMISSION_MODEL_DOWNGRADE") + { + return format!( + "Permission denied: Insufficient privileges to perform this action.\n\ + Error: {error_output}\n\ + Tip: This may require root access or the package is protected by the system." + ); + } + + // Work profile / managed device errors + if error_output.contains("DELETE_FAILED_DEVICE_POLICY_MANAGER") { + return format!( + "Cannot modify: Package is managed by device policy (MDM/EMM).\n\ + Error: {error_output}\n\ + Tip: Contact your IT administrator if this is a work device." + ); + } + + // Generic failure with context + format!("{action} -> {error_output}") +} + /// If `None`, returns an empty String, not " --user 0" pub fn user_flag(user_id: Option) -> String { user_id @@ -221,6 +275,135 @@ pub fn get_android_sdk(device_serial: &str) -> u8 { }) } +/// Capture the current state of a package across all non-protected users. +/// This is used to detect cross-user behavior by comparing before and after states. +/// +/// Only includes users where the package exists (Some state). Users where the package +/// doesn't exist (None) are not tracked. +pub fn capture_cross_user_states( + package_name: &str, + device_serial: &str, + target_user_id: u16, + phone: &Phone, +) -> Vec<(u16, PackageState)> { + phone + .user_list + .iter() + .filter(|u| !u.protected && u.id != target_user_id) + .filter_map(|u| { + verify_package_state(package_name, device_serial, Some(u.id)).map(|state| (u.id, state)) + }) + .collect() +} + +/// Detect cross-user behavior and return appropriate notification message. +/// This handles unexpected cross-user behavior: +/// - Case A: Uninstall → Restore (package appears on other users) +/// - Case B: Uninstall → Uninstall (package disappears from other users that previously had it) +/// - Case C: Restore → Restore (package appears on other users) +pub fn detect_cross_user_behavior( + package_name: &str, + device_serial: &str, + target_user_id: u16, + wanted_state: PackageState, + actual_state: PackageState, + phone: &Phone, + before_states: &[(u16, PackageState)], +) -> Option { + // Only check if operation was successful on target user + if actual_state != wanted_state { + return None; + } + + // Only check if we have multiple users + if phone.user_list.len() < 2 { + return None; + } + + let after_states = + check_cross_user_package_existence(package_name, device_serial, target_user_id, phone); + + match wanted_state { + PackageState::Uninstalled => { + if after_states.is_empty() { + // Case B: Uninstall → Uninstall (check if all users lost package) + let affected_users: Vec<_> = before_states + .iter() + .filter(|(uid, before_state)| { + // Only flag if the package was installed/enabled/disabled before + *before_state != PackageState::Uninstalled + // And is NOT in after_states (doesn't exist in usable state anymore) + && !after_states.iter().any(|(after_uid, _)| after_uid == uid) + }) + .map(|(uid, _)| uid) + .collect(); + + if affected_users.is_empty() { + None + } else { + let user_list = affected_users + .iter() + .map(|uid| format!("user {uid}")) + .collect::>() + .join(", "); + + Some(format!( + "Detected cross-user uninstall: package was also uninstalled from {user_list} after uninstalling from user {target_user_id}" + )) + } + } else { + // Case A: Uninstall → Restore (package appears on other users) + let user_list = after_states + .iter() + .map(|(uid, state)| format!("user {uid} ({state:?})")) + .collect::>() + .join(", "); + + Some(format!( + "Detected cross-user restoration: package exists on {user_list} after uninstalling from user {target_user_id}" + )) + } + } + PackageState::Enabled | PackageState::Disabled => { + // Case C: Restore → Restore (package appears on other users) + + // Check if a user didn't have the package before (not tracked or package didn't exist). + // Detects packages that appear on users where they didn't exist previously (OEM cross-user restoration). + let was_package_absent_before = |uid: &u16| { + before_states + .iter() + .find(|(before_uid, _)| before_uid == uid) + .is_none_or(|(_, before_state)| *before_state == PackageState::Uninstalled) + }; + + let newly_appeared: Vec<_> = after_states + .iter() + .filter(|(uid, _after_state)| was_package_absent_before(uid)) + .collect(); + + if newly_appeared.is_empty() { + None + } else { + let user_list = newly_appeared + .iter() + .map(|(uid, state)| format!("user {uid} ({state:?})")) + .collect::>() + .join(", "); + + Some(format!( + "Detected cross-user restoration: package exists on {user_list} after {} from user {target_user_id}", + if wanted_state == PackageState::Enabled { + "enabling" + } else { + "disabling" + } + )) + } + } + PackageState::All => None, + } +} + /// Minimum inclusive Android SDK version /// that supports multi-user mode. /// Lollipop 5.0 @@ -309,3 +492,186 @@ pub async fn initial_load() -> bool { Err(_err) => false, } } + +/// Verify the actual state of a package on the device +pub fn verify_package_state( + package_name: &str, + device_serial: &str, + user_id: Option, +) -> Option { + use crate::core::adb::{ACommand as AdbCommand, PmListPacksFlag}; + + // Check if package is enabled + if let Ok(enabled_packages) = AdbCommand::new() + .shell(device_serial) + .pm() + .list_packages_sys(Some(PmListPacksFlag::OnlyEnabled), user_id) + && enabled_packages.contains(&package_name.to_string()) + { + return Some(PackageState::Enabled); + } + + // Check if package is disabled + if let Ok(disabled_packages) = AdbCommand::new() + .shell(device_serial) + .pm() + .list_packages_sys(Some(PmListPacksFlag::OnlyDisabled), user_id) + && disabled_packages.contains(&package_name.to_string()) + { + return Some(PackageState::Disabled); + } + + // Check if package exists at all (including uninstalled) + if let Ok(all_packages) = AdbCommand::new() + .shell(device_serial) + .pm() + .list_packages_sys(Some(PmListPacksFlag::IncludeUninstalled), user_id) + && all_packages.contains(&package_name.to_string()) + { + return Some(PackageState::Uninstalled); + } + + // Package not found at all - it doesn't exist on this device/user + None +} + +/// Check if a package exists on any other users besides the target user. +/// This helps detect OEM-specific cross-user restoration behavior. +/// +/// Only includes users where the package exists in a non-uninstalled state +/// (i.e., Enabled or Disabled). +pub fn check_cross_user_package_existence( + package_name: &str, + device_serial: &str, + target_user_id: u16, + phone: &Phone, +) -> Vec<(u16, PackageState)> { + let mut other_user_states = Vec::new(); + + for user in &phone.user_list { + if user.id != target_user_id + && !user.protected + && let Some(state) = verify_package_state(package_name, device_serial, Some(user.id)) + && state != PackageState::Uninstalled + { + other_user_states.push((user.id, state)); + } + } + + other_user_states +} + +/// Attempt fallback action when package state verification fails +pub fn attempt_fallback( + package: &crate::gui::widgets::package_row::PackageRow, + wanted_state: PackageState, + actual_state: PackageState, + user: User, + phone: &Phone, +) -> Result { + match (wanted_state, actual_state) { + // Case 1: Tried to uninstall but package was reinstalled -> disable it + (PackageState::Uninstalled, PackageState::Enabled) => { + let core_package = CorePackage { + name: package.name.clone(), + state: PackageState::Enabled, + }; + let commands = + apply_pkg_state_commands(&core_package, PackageState::Disabled, user, phone); + + if commands.is_empty() { + Err("No disable command available for this Android version".to_string()) + } else { + // Execute the disable command + let action = commands[0].clone(); + match AdbCommand::new().shell(&phone.adb_id).raw(&action) { + Ok(_) => Ok("disabled package instead of uninstalling".to_string()), + Err(err) => Err(format!("Failed to disable package: {err}")), + } + } + } + + // Case 2: Tried to disable but package re-enabled itself -> try uninstall + (PackageState::Disabled, PackageState::Enabled) => { + let core_package = CorePackage { + name: package.name.clone(), + state: PackageState::Enabled, + }; + let commands = + apply_pkg_state_commands(&core_package, PackageState::Uninstalled, user, phone); + + if commands.is_empty() { + Err("No uninstall command available for this Android version".to_string()) + } else { + // Execute the uninstall command + let action = commands[0].clone(); + match AdbCommand::new().shell(&phone.adb_id).raw(&action) { + Ok(_) => { + // Verify the package was actually uninstalled + match verify_package_state(&package.name, &phone.adb_id, Some(user.id)) { + Some(PackageState::Uninstalled) | None => { + Ok("uninstalled package instead of disabling".to_string()) + } + _ => Err("Package still exists after uninstall attempt".to_string()), + } + } + Err(err) => Err(format!("Failed to uninstall package: {err}")), + } + } + } + + // Case 3: Tried to enable but package was disabled -> try uninstall then reinstall + (PackageState::Enabled, PackageState::Disabled) => { + // First try to uninstall + let core_package = CorePackage { + name: package.name.clone(), + state: PackageState::Disabled, + }; + let uninstall_commands = + apply_pkg_state_commands(&core_package, PackageState::Uninstalled, user, phone); + + if uninstall_commands.is_empty() { + Err("No uninstall command available for reinstall attempt".to_string()) + } else { + let uninstall_action = uninstall_commands[0].clone(); + match AdbCommand::new() + .shell(&phone.adb_id) + .raw(&uninstall_action) + { + Ok(_) => { + // Now try to reinstall/enable + let core_package_uninstalled = CorePackage { + name: package.name.clone(), + state: PackageState::Uninstalled, + }; + let enable_commands = apply_pkg_state_commands( + &core_package_uninstalled, + PackageState::Enabled, + user, + phone, + ); + + if enable_commands.is_empty() { + Ok("uninstalled package but couldn't reinstall".to_string()) + } else { + let enable_action = enable_commands[0].clone(); + match AdbCommand::new().shell(&phone.adb_id).raw(&enable_action) { + Ok(_) => { + Ok("uninstalled and reinstalled package to enable it" + .to_string()) + } + Err(err) => Err(format!("Failed to reinstall package: {err}")), + } + } + } + Err(err) => Err(format!("Failed to uninstall package for reinstall: {err}")), + } + } + } + + // Other cases - no fallback available + _ => Err(format!( + "No fallback available for wanted state {wanted_state:?} and actual state {actual_state:?}" + )), + } +} diff --git a/src/core/utils.rs b/src/core/utils.rs index 861a9c3..ed0ccc7 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -144,6 +144,7 @@ pub fn setup_uad_dir(dir: &Path) -> PathBuf { dir } +/// Open a directory or file with the system's default file manager. pub fn open_url(dir: PathBuf) { const OPENER: &str = match std::env::consts::OS.as_bytes() { b"windows" => "explorer", @@ -154,10 +155,14 @@ pub fn open_url(dir: PathBuf) { match std::process::Command::new(OPENER).arg(dir).output() { Ok(o) => { if !o.status.success() { - // does Windows print UTF-16? - match String::from_utf8(o.stderr) { - Ok(s) => error!("Can't open the following URL: {}", s.trim_end()), - Err(_e) => error!("Can't open the following URL: "), + // Use lossy conversion for stderr - some systems (like Windows) + // may output non-UTF8 characters in error messages + let stderr = String::from_utf8_lossy(&o.stderr); + let stderr_trimmed = stderr.trim_end(); + if stderr_trimmed.is_empty() { + error!("Can't open URL: command failed with no error message"); + } else { + error!("Can't open the following URL: {stderr_trimmed}"); } } } diff --git a/src/gui/views/list.rs b/src/gui/views/list.rs index d7ebd63..b282a9a 100644 --- a/src/gui/views/list.rs +++ b/src/gui/views/list.rs @@ -26,6 +26,7 @@ pub struct PackageInfo { pub i_user: usize, pub index: usize, pub removal: String, + pub before_cross_user_states: Vec<(u16, PackageState)>, } #[derive(Default, Debug, Clone)] @@ -65,6 +66,7 @@ pub struct List { current_package_index: usize, is_adb_satisfied: bool, copy_confirmation: bool, + fallback_notifications: Vec, } #[derive(Debug, Clone)] @@ -73,6 +75,8 @@ pub enum Message { LoadPhonePackages((PackageHashMap, UadListState)), RestoringDevice(Result), ApplyFilters(Vec>), + DismissFallbackNotifications, + VerifyAndFallbackFinished(VerifyAndFallbackResult), SearchInputChanged(String), ToggleAllSelected(bool), ListSelected(UadList), @@ -81,7 +85,7 @@ pub enum Message { RemovalSelected(Removal), ApplyActionOnSelection, List(usize, RowMessage), - ChangePackageState(Result), + VerifyAndFallback(Result), Nothing, ModalHide, ModalUserSelected(User), @@ -103,6 +107,15 @@ pub struct SummaryEntry { restore: u8, } +#[derive(Debug, Clone)] +pub struct VerifyAndFallbackResult { + pub i_user: usize, + pub index: usize, + pub new_state: PackageState, + pub notification: Option, + pub error_modal: Option, +} + impl From for SummaryEntry { fn from(category: Removal) -> Self { Self { @@ -130,6 +143,7 @@ impl List { self.on_load_phone_packages(payload, selected_device, list_update_state) } Message::ApplyFilters(packages) => self.on_apply_filters(packages), + Message::DismissFallbackNotifications => self.on_dismiss_fallback_notifications(), Message::ToggleAllSelected(selected) => { self.on_toggle_all_selected(selected, settings, selected_device, list_update_state) } @@ -140,7 +154,12 @@ impl List { Message::List(i, row_msg) => self.on_list_row(i, &row_msg, settings, selected_device), Message::ApplyActionOnSelection => self.on_apply_action_on_selection(), Message::UserSelected(user) => self.on_user_selected(user), - Message::ChangePackageState(res) => self.on_change_package_state(res, settings), + Message::VerifyAndFallback(res) => { + self.on_verify_and_fallback(res, settings, selected_device) + } + Message::VerifyAndFallbackFinished(result) => { + self.on_verify_and_fallback_finished(result) + } Message::ModalUserSelected(user) => { self.on_modal_user_selected(user, settings, selected_device, list_update_state) } @@ -157,6 +176,26 @@ impl List { } } + // Handle verification completion on the UI thread after async work + fn on_verify_and_fallback_finished( + &mut self, + result: VerifyAndFallbackResult, + ) -> Task { + let package = &mut self.phone_packages[result.i_user][result.index]; + if let Some(notification) = result.notification { + self.fallback_notifications.push(notification); + } + if let Some(err) = result.error_modal { + self.error_modal = Some(err); + } + package.state = result.new_state; + package.selected = false; + self.selected_packages + .retain(|&x| x.1 != result.index && x.0 != result.i_user); + Self::filter_package_lists(self); + Task::none() + } + /// Builds the main view for the app list interface pub fn view( &self, @@ -361,6 +400,37 @@ impl List { .style(style::Container::BorderedFrame); let control_panel = self.control_panel(selected_device); + + // Fallback notifications area + let notifications_area: Element<'_, Message, Theme, Renderer> = + if self.fallback_notifications.is_empty() { + Space::new(Length::Shrink, Length::Shrink).into() + } else { + let notification_texts: Vec<_> = self + .fallback_notifications + .iter() + .map(|msg| text(msg).style(style::Text::Commentary).into()) + .collect(); + + container( + column![ + text("Fallback Actions Performed:").style(style::Text::Default), + column(notification_texts).spacing(4), + row![ + Space::new(Length::Fill, Length::Shrink), + button(text("Dismiss")) + .on_press(Message::DismissFallbackNotifications) + .style(style::Button::Primary) + .padding([4, 10]), + ] + ] + .spacing(6), + ) + .padding(8) + .style(style::Container::BorderedFrame) + .into() + }; + let content = if selected_device.user_list.is_empty() || match self.selected_user { Some(u) => !self.phone_packages[u.index].is_empty(), @@ -373,6 +443,7 @@ impl List { } { column![ control_panel, + notifications_area, packages_scrollable, description_panel, action_row, @@ -380,6 +451,7 @@ impl List { } else { column![ control_panel, + notifications_area, container(unavailable) .height(Length::Fill) .center_y(Length::Fill), @@ -739,6 +811,7 @@ impl List { settings: &Settings, selected_device: &mut Phone, ) -> Task { + self.fallback_notifications.clear(); let mut commands = vec![]; self.selected_packages.sort_unstable(); self.selected_packages.dedup(); @@ -807,6 +880,7 @@ impl List { self.selected_removal = Some(Removal::Recommended); self.selected_list = Some(UadList::All); self.selected_user = Some(User::default()); + self.fallback_notifications.clear(); Self::filter_package_lists(self); self.loading_state = LoadingState::Ready; Task::none() @@ -913,6 +987,7 @@ impl List { Task::none() } RowMessage::ActionPressed => { + self.fallback_notifications.clear(); self.phone_packages[i_user][i_package].selected = true; Task::batch(build_action_pkg_commands( &self.phone_packages, @@ -941,30 +1016,142 @@ impl List { fn on_user_selected(&mut self, user: User) -> Task { self.selected_user = Some(user); + self.fallback_notifications.clear(); self.filtered_packages = (0..self.phone_packages[user.index].len()).collect(); Self::filter_package_lists(self); Task::none() } - fn on_change_package_state( + #[allow( + clippy::too_many_lines, + reason = "Complex verification and fallback logic" + )] + fn on_verify_and_fallback( &mut self, res: Result, settings: &Settings, + selected_device: &Phone, ) -> Task { match res { Ok(p) => { - let package = &mut self.phone_packages[p.i_user][p.index]; - package.state = package.state.opposite(settings.device.disable_mode); - package.selected = false; - self.selected_packages - .retain(|&x| x.1 != p.index && x.0 != p.i_user); - Self::filter_package_lists(self); + // Snapshot minimal info to move into background task + let i_user = p.i_user; + let index = p.index; + let pkg_name = self.phone_packages[i_user][index].name.clone(); + let current_state = self.phone_packages[i_user][index].state; + let wanted_state = current_state.opposite(settings.device.disable_mode); + let before_cross_user_states = p.before_cross_user_states.clone(); + let device = selected_device.clone(); + let user_id = device.user_list[i_user].id; + + // Offload verification + potential fallback to background + Task::perform( + async move { + // Blocking ADB calls happen here (off UI thread) + let actual_state_opt = crate::core::sync::verify_package_state( + &pkg_name, + device.adb_id.as_str(), + Some(user_id), + ); + + match actual_state_opt { + Some(actual_state) if actual_state == wanted_state => { + // Check cross-user behavior + let error_modal = crate::core::sync::detect_cross_user_behavior( + &pkg_name, + device.adb_id.as_str(), + user_id, + wanted_state, + actual_state, + &device, + &before_cross_user_states, + ) + .map(|notification| { + format!( + "Cross-User Behavior Detected:\n\n{notification}\n\n\ + This is unusual behavior that may be specific to your device manufacturer (OEM). \ + The package state has been successfully changed on the target user." + ) + }); + + VerifyAndFallbackResult { + i_user, + index, + new_state: wanted_state, + notification: None, + error_modal, + } + } + actual_state_opt => { + // Package doesn't exist (None) or has wrong state - try fallback + let actual_state = + actual_state_opt.unwrap_or(PackageState::Uninstalled); + let fallback_result = crate::core::sync::attempt_fallback( + &crate::gui::widgets::package_row::PackageRow::new( + &pkg_name, + current_state, + "", + UadList::All, + Removal::All, + false, + false, + ), + wanted_state, + actual_state, + device.user_list[i_user], + &device, + ); + + let state_description = match actual_state_opt { + Some(PackageState::Uninstalled) => "remains uninstalled", + Some(PackageState::Disabled) => "was disabled", + Some(PackageState::Enabled) => "was enabled", + Some(PackageState::All) => { + "unexpected state (error determining state)" + } + None => "does not exist on device", + }; + + let (notification, new_state) = match fallback_result { + Ok(fallback_action) => ( + Some(format!( + "Package '{pkg_name}' was {} but {} instead. Fallback: {fallback_action}", + match wanted_state { + PackageState::Uninstalled => "uninstalled", + PackageState::Disabled => "disabled", + PackageState::Enabled => "enabled", + PackageState::All => "modified", + }, + state_description + )), + actual_state, + ), + Err(err) => ( + Some(format!( + "Package '{pkg_name}' verification failed: {err}" + )), + current_state, // no change if fallback failed + ), + }; + + VerifyAndFallbackResult { + i_user, + index, + new_state, + notification, + error_modal: None, + } + } + } + }, + Message::VerifyAndFallbackFinished, + ) } Err(AdbError::Generic(err)) => { self.error_modal = Some(err); + Task::none() } } - Task::none() } fn on_modal_user_selected( @@ -1047,6 +1234,12 @@ impl List { } } +impl List { + fn on_dismiss_fallback_notifications(&mut self) -> Task { + self.fallback_notifications.clear(); + Task::none() + } +} fn error_view<'a>( error: &'a str, content: Column<'a, Message, Theme, Renderer>, @@ -1143,7 +1336,11 @@ fn build_action_pkg_commands( && packages .get(u.index) .and_then(|user_pkgs| user_pkgs.get(selection.1)) - .is_some_and(|row_pkg| row_pkg.selected || settings.multi_user_mode) + .is_some_and(|row_pkg| { + // Only apply to users where package is explicitly selected + // OR if multi_user_mode is enabled AND this is the initiating user + row_pkg.selected || (settings.multi_user_mode && u.index == selection.0) + }) }) { let u_pkg = &packages[u.index][selection.1]; let wanted_state = if settings.multi_user_mode { @@ -1153,24 +1350,28 @@ fn build_action_pkg_commands( }; let actions = apply_pkg_state_commands(&u_pkg.into(), wanted_state, *u, device); + for (j, action) in actions.into_iter().enumerate() { let p_info = PackageInfo { i_user: u.index, index: selection.1, removal: pkg.removal.to_string(), + // Will be filled asynchronously before running the adb action + before_cross_user_states: vec![], }; // In the end there is only one package state change // even if we run multiple adb commands commands.push(Task::perform( - run_adb_action( - // this is typically small, - // so it's fine. + run_adb_action_with_before_states( device.adb_id.clone(), action, p_info, + u_pkg.name.clone(), + u.id, + device.clone(), ), if j == 0 { - Message::ChangePackageState + Message::VerifyAndFallback } else { |_| Message::Nothing }, @@ -1180,6 +1381,25 @@ fn build_action_pkg_commands( commands } +async fn run_adb_action_with_before_states( + device_serial: String, + action: String, + mut p_info: PackageInfo, + package_name: String, + target_user_id: u16, + phone: Phone, +) -> Result { + // Capture before-state in background to avoid blocking UI thread + let before_states = crate::core::sync::capture_cross_user_states( + &package_name, + &device_serial, + target_user_id, + &phone, + ); + p_info.before_cross_user_states = before_states; + run_adb_action(device_serial, action, p_info).await +} + fn recap<'a>(settings: &Settings, recap: &SummaryEntry) -> Element<'a, Message, Theme, Renderer> { container( row![ diff --git a/src/gui/views/settings.rs b/src/gui/views/settings.rs index 36ff399..d2f4813 100644 --- a/src/gui/views/settings.rs +++ b/src/gui/views/settings.rs @@ -218,14 +218,15 @@ impl Settings { nb_running_async_adb_commands: &mut u32, ) -> Task { match restore_backup(phone, packages, &self.device) { - Ok(r_packages) => { + Ok(restore_result) => { let mut commands = vec![]; *nb_running_async_adb_commands = 0; - for p in &r_packages { + for p in &restore_result.packages { let p_info = PackageInfo { - i_user: 0, + i_user: p.i_user, index: p.index, removal: "RESTORE".to_string(), + before_cross_user_states: vec![], }; for command in p.commands.clone() { *nb_running_async_adb_commands += 1; @@ -235,13 +236,20 @@ impl Settings { )); } } - if r_packages.is_empty() { + if restore_result.skipped_count > 0 { + self.device.backup.backup_state = format!( + "Restore completed with {} packages skipped (not found on device)", + restore_result.skipped_count + ); + } else if restore_result.packages.is_empty() { if get_android_sdk(&phone.adb_id) == 0 { self.device.backup.backup_state = "Device is not connected".to_string(); } else { self.device.backup.backup_state = "Device state is already restored".to_string(); } + } else { + self.device.backup.backup_state = "Restore completed successfully".to_string(); } info!( "[RESTORE] Restoring backup {}",