Merge pull request #1167 from iamanaws/fix/core-hardening

fix(core): add package state verification and cross-user detection
This commit is contained in:
Ricardo Fernández Serrata 2025-11-27 11:31:24 -04:00 committed by GitHub
commit 1f71a85071
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 669 additions and 53 deletions

View file

@ -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<u8>) -> 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<str>);
impl PackageId {
/// Creates a package-ID if it's valid according to
/// [this](https://developer.android.com/build/configure-app-module#set-application-id)
/// <https://developer.android.com/build/configure-app-module#set-application-id>
pub fn new(p_id: Box<str>) -> Option<Self> {
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)
/// <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{<id>:<name>:<flags>}[ 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{<id>:<name>:<flags>}[ 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 {

View file

@ -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<User> {
#[derive(Debug)]
pub struct BackupPackage {
pub i_user: usize,
pub index: usize,
pub commands: Vec<String>,
}
#[derive(Debug)]
pub struct RestoreResult {
pub packages: Vec<BackupPackage>,
pub skipped_count: usize,
}
pub fn restore_backup(
selected_device: &Phone,
packages: &[Vec<PackageRow>],
settings: &DeviceSettings,
) -> Result<Vec<BackupPackage>, String> {
) -> Result<RestoreResult, String> {
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()),
}

View file

@ -72,20 +72,74 @@ pub async fn run_adb_action<S: AsRef<str>>(
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<User>) -> 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<String> {
// 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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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<u16>,
) -> Option<PackageState> {
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<String, String> {
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:?}"
)),
}
}

View file

@ -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: <non-UTF8 output>"),
// 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}");
}
}
}

View file

@ -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<String>,
}
#[derive(Debug, Clone)]
@ -73,6 +75,8 @@ pub enum Message {
LoadPhonePackages((PackageHashMap, UadListState)),
RestoringDevice(Result<PackageInfo, AdbError>),
ApplyFilters(Vec<Vec<PackageRow>>),
DismissFallbackNotifications,
VerifyAndFallbackFinished(VerifyAndFallbackResult),
SearchInputChanged(String),
ToggleAllSelected(bool),
ListSelected(UadList),
@ -81,7 +85,7 @@ pub enum Message {
RemovalSelected(Removal),
ApplyActionOnSelection,
List(usize, RowMessage),
ChangePackageState(Result<PackageInfo, AdbError>),
VerifyAndFallback(Result<PackageInfo, AdbError>),
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<String>,
pub error_modal: Option<String>,
}
impl From<Removal> 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<Message> {
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<Message> {
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<Message> {
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<PackageInfo, AdbError>,
settings: &Settings,
selected_device: &Phone,
) -> Task<Message> {
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<Message> {
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<PackageInfo, AdbError> {
// 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![

View file

@ -218,14 +218,15 @@ impl Settings {
nb_running_async_adb_commands: &mut u32,
) -> Task<Message> {
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 {}",