mirror of
https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation.git
synced 2026-08-09 15:19:11 +02:00
Merge pull request #1167 from iamanaws/fix/core-hardening
fix(core): add package state verification and cross-user detection
This commit is contained in:
commit
1f71a85071
6 changed files with 669 additions and 53 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
|||
370
src/core/sync.rs
370
src/core/sync.rs
|
|
@ -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:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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 {}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue