Merge pull request #1297 from iamanaws/feature/cli

feat: add CLI and restructure into multi-package workspace
This commit is contained in:
Anonymoussaurus 2026-06-17 20:49:43 +02:00 committed by GitHub
commit 4773be1f00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 3074 additions and 639 deletions

View file

@ -42,7 +42,7 @@ jobs:
restore-keys: ${{ runner.OS }}-release-
if: matrix.os == 'ubuntu-22.04'
- name: Building
run: cargo build --release --no-default-features --features wgpu,${{ matrix.update_feature }},img
run: cargo build --release -p uad-gui --no-default-features --features wgpu,${{ matrix.update_feature }},img
- name: Renaming binaries [Windows]
if: matrix.os == 'windows-2022'
run: mv target/release/uad-ng.exe uad-ng-${{ matrix.build_target }}.exe
@ -68,3 +68,59 @@ jobs:
with:
name: uad-ng${{ matrix.update_name }}-${{ matrix.build_target }}
path: uad-ng-*
build-cli:
name: Building CLI ${{ matrix.build_target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- build_target: linux
os: ubuntu-22.04
- build_target: macos
os: macos-15
- build_target: macos-intel
os: macos-15-intel
- build_target: windows
os: windows-2022
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target
key: ${{ runner.os }}-release-cli-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.OS }}-release-cli-
if: matrix.os == 'ubuntu-22.04'
- name: Building
run: cargo build --release -p uad-cli
- name: Renaming binaries [Windows]
if: matrix.os == 'windows-2022'
run: mv target/release/uad.exe uad-cli-${{ matrix.build_target }}.exe
- name: Renaming binaries [Others]
if: matrix.os != 'windows-2022'
run: mv target/release/uad uad-cli-${{ matrix.build_target }}
- name: Tarball Linux/MacOS binary
if: matrix.os != 'windows-2022'
run: tar -czf uad-cli-${{ matrix.build_target }}{.tar.gz,}
- name: Install coreutils for macOS
if: startsWith(matrix.os, 'macos')
run: brew install coreutils
- name: Create checksums for binaries and archives [Windows]
if: matrix.os == 'windows-2022'
run: sha256sum uad-cli-${{ matrix.build_target }}.exe | tee uad-cli-${{ matrix.build_target }}.exe-checksum.txt
- name: Create checksums for binaries and archives [Others]
if: matrix.os != 'windows-2022'
run: |
sha256sum uad-cli-${{ matrix.build_target }} | tee uad-cli-${{ matrix.build_target }}-checksum
sha256sum uad-cli-${{ matrix.build_target }}.tar.gz | tee uad-cli-${{ matrix.build_target }}.tar.gz-checksum
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: uad-cli-${{ matrix.build_target }}
path: uad-cli-*

View file

@ -25,7 +25,9 @@ jobs:
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: uad-ng-*/*
files: |
uad-ng-*/*
uad-cli-*/*
generate_release_notes: true
body: |
> [!NOTE]

805
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,12 @@
[package]
name = "uad-ng"
description = "A cross-platform GUI debloater for android devices"
[workspace]
members = [
"crates/uad-core",
"crates/uad-gui",
"crates/uad-cli"
]
resolver = "2"
[workspace.package]
version = "1.2.0"
authors = ["Universal-Debloater-Alliance"]
license = "GPL-3.0"
@ -11,59 +17,38 @@ keywords = ["debloater", "android", "adb", "privacy", "bloatware"]
categories = ["gui"]
edition = "2024"
[features]
default = ["wgpu", "self-update", "img"]
wgpu = [] # Iced/wgpu is default
self-update = ["flate2", "tar"]
no-self-update = []
img = ["image", "iced/image"]
[dependencies]
dark-light = "2"
[workspace.dependencies]
# Core dependencies
serde = { version = "^1.0", features = ["derive"] }
serde_json = "^1.0"
fern = { version = "^0", features = ["colored"] }
chrono = { version = "^0.4", default-features = false, features = [
"std",
"clock",
] }
log = "^0.4"
chrono = { version = "^0.4", default-features = false, features = ["std", "clock"] }
toml = "^0"
dirs = "^6"
ureq = { version = "3", features = ["json"] }
retry = "^2.0.0"
iced = { version = "=0.14.0", features = ["advanced"] }
image = { version = "0.25", optional = true }
rfd = "^0.17.1"
csv = "^1.3"
[target.'cfg(not(target_os = "windows"))'.dependencies]
flate2 = { version = "^1", optional = true }
tar = { version = "^0.4", optional = true }
# GUI dependencies
iced = { version = "=0.14.0", features = ["advanced"] }
image = "0.25"
rfd = "^0.17.1"
dark-light = "2"
fern = { version = "^0", features = ["colored"] }
[target.'cfg(target_os = "windows")'.dependencies]
# CLI dependencies
clap = { version = "4.5", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
# Platform-specific dependencies
flate2 = "^1"
tar = "^0.4"
win32console = "^0.1.5"
[profile.release]
lto = "thin"
opt-level = 2
codegen-units = 4
[profile.opt]
inherits = "release"
opt-level = "s"
codegen-units = 1
lto = "fat"
strip = true
panic = "abort"
[build-dependencies]
embed-resource = "3"
[lints.rust]
[workspace.lints.rust]
deprecated_safe = "warn"
[lints.clippy]
[workspace.lints.clippy]
undocumented_unsafe_blocks = "forbid"
panic_in_result_fn = "warn"
infinite_loop = "warn"
@ -75,4 +60,4 @@ struct_field_names = "allow" # annoying
disallowed_types = "deny"
disallowed_methods = "deny"
pedantic = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }

52
crates/uad-cli/Cargo.toml Normal file
View file

@ -0,0 +1,52 @@
[package]
name = "uad-cli"
version.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
readme.workspace = true
keywords.workspace = true
categories = ["command-line-utilities"]
edition.workspace = true
[[bin]]
name = "uad"
path = "src/main.rs"
[dependencies]
uad-core = { path = "../uad-core" }
clap.workspace = true
clap_complete = "4.4"
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
log.workspace = true
chrono.workspace = true
toml.workspace = true
dirs.workspace = true
ureq.workspace = true
retry.workspace = true
csv.workspace = true
rustyline = "14.0"
[lints.rust]
deprecated_safe = "warn"
[lints.clippy]
undocumented_unsafe_blocks = "deny"
exit = "deny"
panic_in_result_fn = "warn"
infinite_loop = "warn"
mem_forget = "warn"
implicit_clone = "warn"
format_push_string = "warn"
large_include_file = "warn"
shadow_unrelated = "warn"
struct_field_names = "allow" # annoying
disallowed_types = "deny"
disallowed_methods = "deny"
allow_attributes_without_reason = "warn"
pedantic = { level = "warn", priority = -1 }

276
crates/uad-cli/README.md Normal file
View file

@ -0,0 +1,276 @@
# UAD CLI - Universal Android Debloater Command Line Interface
A powerful command-line interface for debloating Android devices, sharing the core functionality with the UAD GUI application.
## Features
- 🚀 **Fast & Efficient** - Direct command execution without GUI overhead
- 📦 **Package Manager-like Interface** - Intuitive commands similar to `apt`, `dnf`, or `pacman`
- 🔍 **Advanced Filtering** - Filter packages by state, removal category, UAD list, or search terms
- 🛡️ **Safety Features** - Dry-run mode, warnings for unsafe packages
- 🔄 **Multiple Actions** - Enable, disable, uninstall, and restore packages
- 🖥️ **Interactive REPL Mode** - Keep device state in memory for faster operations
- 📋 **Shell Completion** - Generate completions for bash, zsh, fish, and more
- 👥 **Multi-user Support** - Target specific Android user profiles
## Installation
Build from source:
```bash
cd /path/to/universal-android-debloater-next-generation
cargo build --release -p uad-cli
```
The binary will be available at `target/release/uad` (the package is `uad-cli`; the installed binary name is `uad`).
## Usage
### List Connected Devices
```bash
uad devices
```
### List Packages
List all packages on the first connected device:
```bash
uad list
# or use the short alias
uad ls
```
Filter packages by state:
```bash
uad list --state enabled
uad list --state disabled
uad list --state uninstalled
```
Filter by removal category:
```bash
uad list --removal recommended
uad list --removal advanced
uad list --removal expert
uad list --removal unsafe
```
Filter by UAD list:
```bash
uad list --list google
uad list --list oem
uad list --list aosp
```
Search for packages:
```bash
uad list --search "facebook"
uad list -q "chrome"
```
Combine filters:
```bash
uad list --state enabled --removal recommended --search "google"
```
### Show Package Information
```bash
uad info com.android.chrome
uad info com.facebook.katana --device SERIAL123
```
### Uninstall Packages
Uninstall one or more packages:
```bash
uad uninstall com.facebook.katana
uad rm com.facebook.katana com.facebook.services
```
Dry-run to see what would happen:
```bash
uad uninstall com.facebook.katana --dry-run
```
Specify device and user:
```bash
uad uninstall com.facebook.katana --device SERIAL123 --user 0
```
### Enable/Restore Packages
```bash
uad enable com.android.chrome
uad restore com.google.android.gms
```
### Disable Packages
```bash
uad disable com.facebook.katana
```
### Update Package Lists
Update UAD lists from the remote repository:
```bash
uad update
```
### Interactive REPL Mode
Start an interactive session for faster repeated operations:
```bash
uad repl
# or
uad shell
```
Within the REPL:
```
uad> help
uad> list --state enabled
uad> info com.facebook.katana
uad> uninstall com.facebook.katana
uad> enable com.android.chrome
uad> device
uad> exit
```
The REPL mode:
- Keeps device state in memory (faster operations)
- Saves command history (accessible with up/down arrows)
- Loads UAD lists once at startup
- Supports all the same commands as the CLI
### Shell Completion
Generate completion scripts for your shell:
```bash
# Bash
uad completions bash > /usr/share/bash-completion/completions/uad
# Zsh
uad completions zsh > /usr/local/share/zsh/site-functions/_uad
# Fish
uad completions fish > ~/.config/fish/completions/uad.fish
# PowerShell
uad completions powershell > uad.ps1
```
## Command Reference
| Command | Aliases | Description |
|---------|---------|-------------|
| `devices` | - | List connected Android devices |
| `list` | `ls` | List packages with optional filters |
| `info` | - | Show detailed package information |
| `uninstall` | `rm` | Uninstall one or more packages |
| `enable` | `restore` | Enable/restore packages |
| `disable` | - | Disable packages (keeps data) |
| `update` | - | Update UAD package lists |
| `completions` | - | Generate shell completions |
| `repl` | `shell` | Start interactive mode |
## Examples
### Remove Facebook packages
```bash
uad list --search facebook
uad uninstall com.facebook.katana com.facebook.services --dry-run
# If it looks good:
uad uninstall com.facebook.katana com.facebook.services
```
### Debloat recommended Google apps
```bash
# See what would be uninstalled
uad list --removal recommended --list google
# Uninstall them (you'd list the actual package names)
uad uninstall com.google.package1 com.google.package2
```
### Work with a specific device
```bash
# List devices
uad devices
# Use specific device
uad list --device SERIAL123
uad uninstall com.facebook.katana --device SERIAL123
```
## Safety
- **Dry-run mode**: Always test with `--dry-run` first
- **Warnings**: The CLI warns you about packages marked as "Unsafe"
- **Reversible**: Most operations can be reversed with the `enable` command
- **Multi-user aware**: Respects Android's multi-user system
## Comparison with GUI
### CLI Advantages:
- Faster for power users
- Scriptable and automatable
- Lower resource usage
- Works over SSH
- Better for batch operations
- Can run on Android itself (though not recommended for same-device debloating)
### GUI Advantages:
- Visual feedback
- Easier for beginners
- Package descriptions always visible
- Batch selection with checkboxes
## Requirements
- ADB (Android Debug Bridge) installed and in PATH
- Android device with USB debugging enabled
- Device authorized in ADB (run `adb devices` to check)
## Troubleshooting
**No devices found**
- Ensure USB debugging is enabled on your device
- Run `adb devices` to check if device is authorized
- Try unplugging and replugging the device
**Package not found**
- The package might already be uninstalled
- Check the exact package name with `adb shell pm list packages`
- Try running `uad update` to refresh package lists
**Permission denied**
- Some system packages can't be modified without root
- Some devices have locked bootloaders preventing certain operations
## Contributing
See the main project [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines.
## License
GPL-3.0 - See [LICENSE](../../LICENSE) for details.

View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Example usage script for UAD CLI
# Check if uad-cli is available
if ! command -v uad &> /dev/null; then
echo "uad command not found. Please build and install uad-cli first."
exit 1
fi
echo "=== UAD CLI Usage Examples ==="
echo
echo "1. List connected devices:"
uad devices
echo
echo "2. List all enabled packages:"
uad list --state enabled | head -20
echo "... (showing first 20)"
echo
echo "3. Search for Google packages:"
uad list --search google | head -10
echo "... (showing first 10)"
echo
echo "4. Show info about a package (example):"
echo "uad info com.google.android.gms"
echo
echo "5. Dry-run uninstall (safe to test):"
echo "uad uninstall com.example.package --dry-run"
echo
echo "6. Start interactive mode:"
echo "uad repl"
echo
echo "=== Generate shell completions ==="
echo "For bash: uad completions bash > ~/.local/share/bash-completion/completions/uad"
echo "For zsh: uad completions zsh > ~/.local/share/zsh/site-functions/_uad"
echo "For fish: uad completions fish > ~/.config/fish/completions/uad.fish"

View file

@ -0,0 +1,476 @@
use clap::CommandFactory;
use clap_complete::{Shell, generate};
use std::collections::{HashMap, HashSet};
use std::io::Write;
use uad_core::adb::{ACommand, PmListPacksFlag};
use uad_core::sync::{
CorePackage, Phone, User, apply_pkg_state_commands, get_devices_list, get_package_state,
run_adb_shell_action,
};
use uad_core::uad_lists::{Package, PackageState, Removal, UadList, load_debloat_lists};
use uad_core::utils::{matches_search, truncate_description};
use crate::device::{get_target_device, get_user};
use crate::filters::{ListFilter, RemovalFilter, StateFilter};
use crate::{Cli, print_or_exit, println_or_exit};
/// List all connected Android devices
pub fn list_devices() -> Result<(), Box<dyn std::error::Error>> {
println_or_exit!("Scanning for connected devices...");
let devices = get_devices_list();
if devices.is_empty() {
eprintln!("No devices found. Make sure ADB is installed and devices are connected.");
return Err("No devices found".into());
}
println_or_exit!("\nFound {} device(s):\n", devices.len());
for device in &devices {
println_or_exit!(" Model: {}", device.model);
println_or_exit!(" Serial: {}", device.adb_id);
println_or_exit!(" Android SDK: {}", device.android_sdk);
if !device.user_list.is_empty() {
println_or_exit!(" Users: {} user(s)", device.user_list.len());
for user in &device.user_list {
let protected = if user.protected { " (protected)" } else { "" };
println_or_exit!(" - User ID: {}{}", user.id, protected);
}
}
println_or_exit!();
}
Ok(())
}
/// Context for package filtering and display
pub struct PackageListContext {
pub state_filter: Option<StateFilter>,
pub removal_filter: Option<RemovalFilter>,
pub list_filter: Option<ListFilter>,
pub search: Option<String>,
}
impl PackageListContext {
/// Check if package passes all filters
pub fn filter_package(
&self,
pkg_name: &str,
pkg_info: Option<&Package>,
pkg_state: PackageState,
) -> bool {
// Removal filter
if let Some(removal) = self.removal_filter {
if !removal.matches(pkg_info) {
return false;
}
}
// State filter
if let Some(state) = self.state_filter {
if !state.matches(pkg_state) {
return false;
}
}
// List filter
if let Some(list) = self.list_filter {
if !list.matches(pkg_info) {
return false;
}
}
// Search filter
if let Some(ref search_term) = self.search {
let description = pkg_info.map(|p| p.description.as_str());
if !matches_search(pkg_name, search_term, description) {
return false;
}
}
true
}
/// Determine what information to show based on active filters
pub fn display_config(&self) -> DisplayConfig {
DisplayConfig {
show_state: self.state_filter.is_none_or(|f| !f.is_specific()),
show_removal: self.removal_filter.is_none_or(|f| !f.is_specific()),
}
}
}
pub struct DisplayConfig {
pub show_state: bool,
pub show_removal: bool,
}
/// List packages on a device with filtering
pub fn list_packages(
device: Option<String>,
state_filter: Option<StateFilter>,
removal_filter: Option<RemovalFilter>,
list_filter: Option<ListFilter>,
search: Option<String>,
user_id: Option<u16>,
) -> Result<(), Box<dyn std::error::Error>> {
let target_device = get_target_device(device)?;
let uad_lists = load_debloat_lists(false).unwrap_or_else(|lists| lists);
println_or_exit!(
"Listing packages on: {} ({})\n",
target_device.model,
target_device.adb_id
);
let context = PackageListContext {
state_filter,
removal_filter,
list_filter,
search,
};
let pm_flag = state_filter.and_then(StateFilter::to_pm_flag);
let system_packages = ACommand::new()
.shell(&target_device.adb_id)
.pm()
.list_packages_sys(pm_flag, user_id)?;
let displayed_count = display_package_list(
&system_packages,
&uad_lists,
&target_device.adb_id,
user_id,
&context,
)?;
if displayed_count == 0 {
println_or_exit!(" No packages found matching the specified filters.");
} else {
println_or_exit!("\nTotal: {} package(s)", displayed_count);
}
Ok(())
}
/// Display a list of packages with their info
pub fn display_package_list(
packages: &[String],
uad_lists: &HashMap<String, Package>,
device_serial: &str,
user_id: Option<u16>,
context: &PackageListContext,
) -> Result<usize, Box<dyn std::error::Error>> {
let config = context.display_config();
let mut displayed_count = 0;
let enabled_packages: HashSet<String> = ACommand::new()
.shell(device_serial)
.pm()
.list_packages_sys(Some(PmListPacksFlag::OnlyEnabled), user_id)
.unwrap_or_default()
.into_iter()
.collect();
let disabled_packages: HashSet<String> = ACommand::new()
.shell(device_serial)
.pm()
.list_packages_sys(Some(PmListPacksFlag::OnlyDisabled), user_id)
.unwrap_or_default()
.into_iter()
.collect();
for pkg_name in packages {
let pkg_info = uad_lists.get(pkg_name);
let pkg_state = if enabled_packages.contains(pkg_name) {
PackageState::Enabled
} else if disabled_packages.contains(pkg_name) {
PackageState::Disabled
} else {
PackageState::Uninstalled
};
if !context.filter_package(pkg_name, pkg_info, pkg_state) {
continue;
}
display_package_entry(pkg_name, pkg_info, pkg_state, &config);
displayed_count += 1;
}
Ok(displayed_count)
}
/// Display a single package entry
pub fn display_package_entry(
pkg_name: &str,
pkg_info: Option<&Package>,
pkg_state: PackageState,
config: &DisplayConfig,
) {
print_or_exit!("[");
if let Some(info) = pkg_info {
if config.show_removal {
print_or_exit!("{}", info.removal);
if config.show_state {
print_or_exit!(" - ");
}
}
if config.show_state {
print_or_exit!("{}", pkg_state);
}
print_or_exit!("] {}", pkg_name);
if !info.description.is_empty() {
print_or_exit!(" - {}", truncate_description(&info.description, 80));
}
} else {
if config.show_removal {
print_or_exit!("Unlisted");
if config.show_state {
print_or_exit!(" - ");
}
}
if config.show_state {
print_or_exit!("{}", pkg_state);
}
print_or_exit!("] {}", pkg_name);
}
println_or_exit!();
}
/// Change the state of one or more packages
pub fn change_package_state(
packages: &[String],
device: Option<String>,
user_id: Option<u16>,
dry_run: bool,
target_state: PackageState,
action_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if packages.is_empty() {
eprintln!("Error: No packages specified");
return Err("No packages specified".into());
}
let target_device = get_target_device(device)?;
let user = get_user(&target_device, user_id)?;
let uad_lists = load_debloat_lists(false).unwrap_or_else(|lists| lists);
println!(
"{} {} package(s) on: {} ({})\n",
action_name,
packages.len(),
target_device.model,
target_device.adb_id
);
if dry_run {
println!("DRY RUN - No changes will be made\n");
}
for pkg_name in packages {
process_package_state_change(
pkg_name,
&target_device,
user,
target_state,
dry_run,
&uad_lists,
)?;
println!();
}
if dry_run {
println!("Dry run completed. No changes were made.");
} else {
println!("Operation completed successfully.");
}
Ok(())
}
/// Process state change for a single package
fn process_package_state_change(
pkg_name: &str,
device: &Phone,
user: User,
target_state: PackageState,
dry_run: bool,
uad_lists: &HashMap<String, Package>,
) -> Result<(), Box<dyn std::error::Error>> {
let current_state = get_package_state(&device.adb_id, pkg_name, Some(user.id))
.ok_or("Package not found on device")?;
println!(" {} ({})", pkg_name, current_state);
if current_state == target_state {
println!(" → Already in target state, skipping");
return Ok(());
}
let pkg_info = uad_lists.get(pkg_name);
if let Some(info) = pkg_info {
if info.removal == Removal::Unsafe {
println!(" ⚠ WARNING: This package is marked as UNSAFE to remove!");
}
}
let core_pkg = CorePackage {
name: pkg_name.to_string(),
description: pkg_info.map(|p| p.description.clone()).unwrap_or_default(),
removal: pkg_info.map(|p| p.removal).unwrap_or(Removal::Unlisted),
state: current_state,
list: pkg_info.map(|p| p.list).unwrap_or(UadList::Unlisted),
};
let commands = apply_pkg_state_commands(&core_pkg, target_state, user, device);
if dry_run {
for cmd in &commands {
println!(" Would run: {}", cmd);
}
} else {
execute_with_fallback(
pkg_name,
target_state,
&core_pkg,
user,
device,
&commands,
" ",
)?;
}
Ok(())
}
/// Execute commands and verify package state with fallback
pub fn execute_with_fallback(
package: &str,
target_state: PackageState,
core_pkg: &CorePackage,
user: User,
device: &Phone,
commands: &[String],
indent: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Capture the before-state of packages on other users for cross-user detection
let before_cross_user_states =
uad_core::sync::capture_cross_user_states(package, &device.adb_id, user.id, device);
// Execute commands
for cmd in commands {
match run_adb_shell_action(&device.adb_id, cmd.as_str()) {
Ok(_) => println!("{}{}", indent, cmd),
Err(e) => {
eprintln!("{}✗ Failed: {:?}", indent, e);
return Err(format!("Failed to execute: {}", cmd).into());
}
}
}
// Verify package state and attempt fallback if needed
let actual_state = match get_package_state(&device.adb_id, package, Some(user.id)) {
Some(state) => state,
None if target_state == PackageState::Uninstalled => PackageState::Uninstalled,
None => {
return Err(format!(
"Package '{package}' was not found on device for user {}",
user.id
)
.into());
}
};
if actual_state != target_state {
println!(
"{}⚠ Package state verification failed: expected {:?}, got {:?}",
indent, target_state, actual_state
);
// Attempt fallback
if let Ok(fallback_action) =
uad_core::sync::attempt_fallback(core_pkg, target_state, actual_state, user, device)
{
println!("{}↻ Fallback: {}", indent, fallback_action);
} else {
println!("{}✗ No fallback available", indent);
}
}
// Check for cross-user behavior if operation succeeded
if actual_state == target_state {
if let Some(notification) = uad_core::sync::detect_cross_user_behavior(
package,
device.adb_id.as_str(),
user.id,
target_state,
actual_state,
device,
&before_cross_user_states,
) {
println!("{} {}", indent, notification);
}
}
Ok(())
}
/// Show detailed information about a package
pub fn show_package_info(
package: &str,
device: Option<String>,
user_id: Option<u16>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Package: {}\n", package);
let uad_lists = load_debloat_lists(false).unwrap_or_else(|lists| lists);
if let Some(pkg_info) = uad_lists.get(package) {
println!("UAD Information:");
println!(" List: {}", pkg_info.list);
println!(" Removal: {}", pkg_info.removal);
println!(" Description: {}", pkg_info.description);
println!();
} else {
println!(" Not found in UAD lists (unlisted package)\n");
}
if let Some(device_id) = device {
let target_device = get_target_device(Some(device_id))?;
let user = get_user(&target_device, user_id)?;
println!("Device: {} ({})", target_device.model, target_device.adb_id);
println!("User: {}", user.id);
let state = get_package_state(&target_device.adb_id, package, Some(user.id))
.ok_or("Package not found on device")?;
println!(" State: {}", state);
}
Ok(())
}
/// Update UAD package lists from remote repository
pub fn update_lists() -> Result<(), Box<dyn std::error::Error>> {
println!("Updating UAD package lists from remote repository...");
match load_debloat_lists(true) {
Ok(_lists) => {
println!("✓ Successfully updated package lists");
Ok(())
}
Err(_lists) => {
eprintln!("✗ Failed to update lists from remote, using cached version");
Err("Failed to update lists".into())
}
}
}
/// Generate shell completion script
pub fn generate_completions(shell: Shell) {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
generate(shell, &mut cmd, name, &mut std::io::stdout());
}

View file

@ -0,0 +1,45 @@
use uad_core::sync::{Phone, User, get_devices_list};
/// Get target device, either by serial or first available
pub fn get_target_device(device: Option<String>) -> Result<Phone, Box<dyn std::error::Error>> {
let devices = get_devices_list();
if devices.is_empty() {
eprintln!("Error: No devices found");
return Err("No devices found".into());
}
let target_device = if let Some(device_id) = device {
devices
.iter()
.find(|d| d.adb_id == device_id)
.ok_or("Device not found")?
.clone()
} else {
if devices.len() > 1 {
eprintln!(
"Warning: Multiple devices found, using first one: {}",
devices[0].adb_id
);
}
devices[0].clone()
};
Ok(target_device)
}
/// Get user from device, creating a basic one if not found
pub fn get_user(device: &Phone, user_id: Option<u16>) -> Result<User, Box<dyn std::error::Error>> {
let uid = user_id.unwrap_or(0);
if let Some(user) = device.user_list.iter().find(|u| u.id == uid) {
Ok(*user)
} else {
// Create a basic user if not found in list
Ok(User {
id: uid,
index: 0,
protected: false,
})
}
}

View file

@ -0,0 +1,98 @@
use clap::ValueEnum;
use uad_core::adb::PmListPacksFlag;
use uad_core::uad_lists::{Package, PackageState, Removal, UadList};
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum StateFilter {
/// Show all packages regardless of state
All,
/// Show only enabled packages
Enabled,
/// Show only disabled packages
Disabled,
/// Show only uninstalled packages
Uninstalled,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum RemovalFilter {
All,
Recommended,
Advanced,
Expert,
Unsafe,
Unlisted,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ListFilter {
All,
Aosp,
Carrier,
Google,
Misc,
Oem,
Pending,
Unlisted,
}
impl StateFilter {
pub fn to_pm_flag(self) -> Option<PmListPacksFlag> {
match self {
Self::Enabled => Some(PmListPacksFlag::OnlyEnabled),
Self::Disabled => Some(PmListPacksFlag::OnlyDisabled),
Self::Uninstalled | Self::All => Some(PmListPacksFlag::IncludeUninstalled),
}
}
pub fn matches(self, pkg_state: PackageState) -> bool {
match self {
Self::All => true,
Self::Enabled => pkg_state == PackageState::Enabled,
Self::Disabled => pkg_state == PackageState::Disabled,
Self::Uninstalled => pkg_state == PackageState::Uninstalled,
}
}
pub fn is_specific(self) -> bool {
matches!(self, Self::Enabled | Self::Disabled | Self::Uninstalled)
}
}
impl RemovalFilter {
pub fn matches(self, pkg_info: Option<&Package>) -> bool {
match pkg_info {
Some(info) => match self {
Self::All => true,
Self::Recommended => info.removal == Removal::Recommended,
Self::Advanced => info.removal == Removal::Advanced,
Self::Expert => info.removal == Removal::Expert,
Self::Unsafe => info.removal == Removal::Unsafe,
Self::Unlisted => info.removal == Removal::Unlisted,
},
None => matches!(self, Self::All | Self::Unlisted),
}
}
pub fn is_specific(self) -> bool {
!matches!(self, Self::All)
}
}
impl ListFilter {
pub fn matches(self, pkg_info: Option<&Package>) -> bool {
match pkg_info {
Some(info) => match self {
Self::All => true,
Self::Aosp => info.list == UadList::Aosp,
Self::Carrier => info.list == UadList::Carrier,
Self::Google => info.list == UadList::Google,
Self::Misc => info.list == UadList::Misc,
Self::Oem => info.list == UadList::Oem,
Self::Pending => info.list == UadList::Pending,
Self::Unlisted => info.list == UadList::Unlisted,
},
None => matches!(self, Self::All | Self::Unlisted),
}
}
}

240
crates/uad-cli/src/main.rs Normal file
View file

@ -0,0 +1,240 @@
#![allow(
clippy::needless_continue,
clippy::collapsible_if,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::unnecessary_wraps,
reason = "Suppress non-critical pedantic/style lints to keep build green"
)]
use clap::{Parser, Subcommand};
use clap_complete::Shell;
use uad_core::uad_lists::PackageState;
mod commands;
mod device;
mod filters;
mod output;
mod repl;
use filters::{ListFilter, RemovalFilter, StateFilter};
#[derive(Parser)]
#[command(name = "uad")]
#[command(about = "Universal Android Debloater - Command Line Interface", long_about = None)]
#[command(version)]
#[command(propagate_version = true)]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// List connected Android devices
Devices,
/// List packages on a device
#[command(name = "list", visible_alias = "ls")]
List {
/// Device serial number (optional, uses first device if not specified)
#[arg(short, long)]
device: Option<String>,
/// Filter by package state
#[arg(short, long, value_enum)]
state: Option<StateFilter>,
/// Filter by removal category
#[arg(short, long, value_enum)]
removal: Option<RemovalFilter>,
/// Filter by UAD list
#[arg(short, long, value_enum)]
list: Option<ListFilter>,
/// Search pattern (matches package name or description)
#[arg(short = 'q', long)]
search: Option<String>,
/// User ID (defaults to 0)
#[arg(short, long)]
user: Option<u16>,
},
/// Uninstall packages (default removal action)
#[command(visible_alias = "rm")]
Uninstall {
/// Package names to uninstall
packages: Vec<String>,
/// Device serial number (optional, uses first device if not specified)
#[arg(short, long)]
device: Option<String>,
/// User ID (defaults to 0)
#[arg(short, long)]
user: Option<u16>,
/// Dry run - show what would be done without actually doing it
#[arg(long)]
dry_run: bool,
},
/// Restore (reinstall) packages
#[command(visible_alias = "restore")]
Enable {
/// Package names to restore/enable
packages: Vec<String>,
/// Device serial number (optional, uses first device if not specified)
#[arg(short, long)]
device: Option<String>,
/// User ID (defaults to 0)
#[arg(short, long)]
user: Option<u16>,
/// Dry run - show what would be done without actually doing it
#[arg(long)]
dry_run: bool,
},
/// Disable packages (keeps data but prevents execution)
Disable {
/// Package names to disable
packages: Vec<String>,
/// Device serial number (optional, uses first device if not specified)
#[arg(short, long)]
device: Option<String>,
/// User ID (defaults to 0)
#[arg(short, long)]
user: Option<u16>,
/// Dry run - show what would be done without actually doing it
#[arg(long)]
dry_run: bool,
},
/// Show detailed information about a package
Info {
/// Package name
package: String,
/// Device serial number (optional, uses first device if not specified)
#[arg(short, long)]
device: Option<String>,
/// User ID (defaults to 0)
#[arg(short, long)]
user: Option<u16>,
},
/// Update UAD package lists from remote repository
Update,
/// Generate shell completion script
Completions {
/// Shell to generate completions for
#[arg(value_enum)]
shell: Shell,
},
/// Start interactive REPL mode
#[command(visible_alias = "shell")]
Repl {
/// Device serial number (optional, uses first device if not specified)
#[arg(short, long)]
device: Option<String>,
/// User ID (defaults to 0)
#[arg(short, long)]
user: Option<u16>,
},
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Commands::Devices => {
commands::list_devices()?;
}
Commands::List {
device,
state,
removal,
list,
search,
user,
} => {
commands::list_packages(device, state, removal, list, search, user)?;
}
Commands::Uninstall {
packages,
device,
user,
dry_run,
} => {
commands::change_package_state(
&packages,
device,
user,
dry_run,
PackageState::Uninstalled,
"Uninstalling",
)?;
}
Commands::Enable {
packages,
device,
user,
dry_run,
} => {
commands::change_package_state(
&packages,
device,
user,
dry_run,
PackageState::Enabled,
"Enabling",
)?;
}
Commands::Disable {
packages,
device,
user,
dry_run,
} => {
commands::change_package_state(
&packages,
device,
user,
dry_run,
PackageState::Disabled,
"Disabling",
)?;
}
Commands::Info {
package,
device,
user,
} => {
commands::show_package_info(&package, device, user)?;
}
Commands::Update => {
commands::update_lists()?;
}
Commands::Completions { shell } => {
commands::generate_completions(shell);
}
Commands::Repl { device, user } => {
repl::repl_mode(device, user)?;
}
}
Ok(())
}

View file

@ -0,0 +1,18 @@
/// Helper macro to handle broken pipe errors gracefully
/// When piping to commands like `head`, we want to exit cleanly when the pipe closes
#[macro_export]
macro_rules! println_or_exit {
() => {
let _ = writeln!(std::io::stdout());
};
($($arg:tt)*) => {
let _ = writeln!(std::io::stdout(), $($arg)*);
};
}
#[macro_export]
macro_rules! print_or_exit {
($($arg:tt)*) => {
let _ = write!(std::io::stdout(), $($arg)*);
};
}

374
crates/uad-cli/src/repl.rs Normal file
View file

@ -0,0 +1,374 @@
use rustyline::DefaultEditor;
use rustyline::error::ReadlineError;
use std::collections::HashMap;
use std::io::Write;
use uad_core::adb::ACommand;
use uad_core::sync::{CorePackage, Phone, User, apply_pkg_state_commands, get_package_state};
use uad_core::uad_lists::{Package, PackageState, Removal, UadList, load_debloat_lists};
use crate::commands::{PackageListContext, display_package_list, execute_with_fallback};
use crate::device::{get_target_device, get_user};
use crate::filters::StateFilter;
use crate::println_or_exit;
enum ReplAction {
Continue,
Exit,
}
/// Start interactive REPL mode
pub fn repl_mode(
device: Option<String>,
user_id: Option<u16>,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Universal Android Debloater - Interactive Mode");
println!("Type 'help' for available commands, 'exit' or 'quit' to leave\n");
let target_device = get_target_device(device)?;
let user = get_user(&target_device, user_id)?;
println!(
"Connected to: {} ({})",
target_device.model, target_device.adb_id
);
println!("User: {}\n", user.id);
let uad_lists = load_debloat_lists(false).unwrap_or_else(|lists| lists);
let mut rl = DefaultEditor::new()?;
// Try to load history
let history_file = dirs::cache_dir().map(|d| d.join("uad").join("cli_history.txt"));
if let Some(ref path) = history_file {
let _ = rl.load_history(path);
}
loop {
let readline = rl.readline("uad> ");
match readline {
Ok(line) => {
match handle_repl_line(&line, &mut rl, &target_device, user, user_id, &uad_lists) {
Ok(ReplAction::Continue) => {}
Ok(ReplAction::Exit) => break,
Err(e) => {
eprintln!("Error: {}", e);
}
}
}
Err(ReadlineError::Interrupted) => {
println!("Interrupted (Ctrl-C). Type 'exit' to quit.");
continue;
}
Err(ReadlineError::Eof) => {
println!("Goodbye!");
break;
}
Err(err) => {
eprintln!("Error: {:?}", err);
break;
}
}
}
// Save history
if let Some(ref path) = history_file {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = rl.save_history(path);
}
Ok(())
}
/// Handle a single line of REPL input
fn handle_repl_line(
line: &str,
rl: &mut DefaultEditor,
device: &Phone,
user: User,
user_id: Option<u16>,
uad_lists: &HashMap<String, Package>,
) -> Result<ReplAction, Box<dyn std::error::Error>> {
let line = line.trim();
if line.is_empty() {
return Ok(ReplAction::Continue);
}
let _ = rl.add_history_entry(line);
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
return Ok(ReplAction::Continue);
}
match parts[0] {
"help" => print_repl_help(),
"exit" | "quit" => {
println!("Goodbye!");
return Ok(ReplAction::Exit);
}
"list" | "ls" => {
handle_list_command(&parts[1..], device, user_id, uad_lists)?;
}
"info" => {
handle_info_command(&parts[1..], device, user.id, uad_lists)?;
}
"uninstall" | "rm" => {
handle_state_change_command(
&parts[1..],
device,
user,
PackageState::Uninstalled,
"Uninstalling",
uad_lists,
)?;
}
"enable" | "restore" => {
handle_state_change_command(
&parts[1..],
device,
user,
PackageState::Enabled,
"Enabling",
uad_lists,
)?;
}
"disable" => {
handle_state_change_command(
&parts[1..],
device,
user,
PackageState::Disabled,
"Disabling",
uad_lists,
)?;
}
"device" => {
println!(
"Device: {} ({}), Android SDK: {}, User: {}",
device.model, device.adb_id, device.android_sdk, user.id
);
}
"clear" => {
print!("\x1B[2J\x1B[1;1H");
}
_ => {
eprintln!(
"Unknown command: '{}'. Type 'help' for available commands.",
parts[0]
);
}
}
Ok(ReplAction::Continue)
}
/// Parse REPL arguments into filters
struct ReplListArgs {
state_filter: Option<StateFilter>,
search_term: Option<String>,
}
impl ReplListArgs {
fn parse(args: &[&str]) -> Result<Self, String> {
let mut state_filter = None;
let mut search_term = None;
let mut i = 0;
while i < args.len() {
match args[i] {
"--state" | "-s" => {
if i + 1 >= args.len() {
return Err("--state requires a value".to_string());
}
state_filter = match args[i + 1].to_lowercase().as_str() {
"enabled" => Some(StateFilter::Enabled),
"disabled" => Some(StateFilter::Disabled),
"uninstalled" => Some(StateFilter::Uninstalled),
"all" => Some(StateFilter::All),
_ => return Err(format!("Invalid state: {}", args[i + 1])),
};
i += 2;
}
"--search" | "-q" => {
if i + 1 >= args.len() {
return Err("--search requires a value".to_string());
}
search_term = Some(args[i + 1].to_string());
i += 2;
}
_ => {
return Err(format!("Unknown option: {}", args[i]));
}
}
}
Ok(Self {
state_filter,
search_term,
})
}
}
/// Handle list command in REPL
fn handle_list_command(
args: &[&str],
device: &Phone,
user_id: Option<u16>,
uad_lists: &HashMap<String, Package>,
) -> Result<(), Box<dyn std::error::Error>> {
let parsed = ReplListArgs::parse(args)?;
let pm_flag = parsed.state_filter.and_then(StateFilter::to_pm_flag);
let system_packages = ACommand::new()
.shell(&device.adb_id)
.pm()
.list_packages_sys(pm_flag, user_id)?;
let context = PackageListContext {
state_filter: parsed.state_filter,
removal_filter: None,
list_filter: None,
search: parsed.search_term,
};
let displayed_count = display_package_list(
&system_packages,
uad_lists,
&device.adb_id,
user_id,
&context,
)?;
if displayed_count == 0 {
println_or_exit!(" No packages found.");
} else {
println_or_exit!("\nTotal: {} package(s)", displayed_count);
}
Ok(())
}
/// Handle info command in REPL
fn handle_info_command(
args: &[&str],
device: &Phone,
user_id: u16,
uad_lists: &HashMap<String, Package>,
) -> Result<(), Box<dyn std::error::Error>> {
if args.is_empty() {
eprintln!("Usage: info <package_name>");
return Ok(());
}
let package = args[0];
println!("Package: {}", package);
if let Some(pkg_info) = uad_lists.get(package) {
println!(" List: {}", pkg_info.list);
println!(" Removal: {}", pkg_info.removal);
println!(" Description: {}", pkg_info.description);
} else {
println!(" Not found in UAD lists (unlisted package)");
}
let state = get_package_state(&device.adb_id, package, Some(user_id))
.ok_or("Package not found on device")?;
println!(" State: {}", state);
Ok(())
}
/// Handle state change command in REPL
fn handle_state_change_command(
args: &[&str],
device: &Phone,
user: User,
target_state: PackageState,
action_name: &str,
uad_lists: &HashMap<String, Package>,
) -> Result<(), Box<dyn std::error::Error>> {
if args.is_empty() {
eprintln!(
"Usage: {} <package_name> [package_name...]",
action_name.to_lowercase()
);
return Ok(());
}
for pkg_name in args {
process_package_change(pkg_name, device, user, target_state, action_name, uad_lists)?;
}
Ok(())
}
/// Process state change for a single package in REPL
fn process_package_change(
pkg_name: &str,
device: &Phone,
user: User,
target_state: PackageState,
action_name: &str,
uad_lists: &HashMap<String, Package>,
) -> Result<(), Box<dyn std::error::Error>> {
let current_state = get_package_state(&device.adb_id, pkg_name, Some(user.id))
.ok_or("Package not found on device")?;
println!("{} {} (current: {})", action_name, pkg_name, current_state);
if current_state == target_state {
println!(" → Already in target state, skipping");
return Ok(());
}
let pkg_info = uad_lists.get(pkg_name);
if let Some(info) = pkg_info {
if info.removal == Removal::Unsafe {
println!(" ⚠ WARNING: This package is marked as UNSAFE to remove!");
}
}
let core_pkg = CorePackage {
name: pkg_name.to_string(),
description: pkg_info.map(|p| p.description.clone()).unwrap_or_default(),
removal: pkg_info.map(|p| p.removal).unwrap_or(Removal::Unlisted),
state: current_state,
list: pkg_info.map(|p| p.list).unwrap_or(UadList::Unlisted),
};
let commands = apply_pkg_state_commands(&core_pkg, target_state, user, device);
execute_with_fallback(
pkg_name,
target_state,
&core_pkg,
user,
device,
&commands,
" ",
)
}
/// Print REPL help message
fn print_repl_help() {
println!("Available commands:");
println!(" list [--state <state>] [--search <term>]");
println!(" List packages with optional filters");
println!(" info <package_name>");
println!(" Show information about a package");
println!(" uninstall <package_name> [package_name...]");
println!(" Uninstall one or more packages");
println!(" enable <package_name> [package_name...]");
println!(" Enable/restore one or more packages");
println!(" disable <package_name> [package_name...]");
println!(" Disable one or more packages");
println!(" device");
println!(" Show current device information");
println!(" clear");
println!(" Clear the screen");
println!(" help");
println!(" Show this help message");
println!(" exit, quit");
println!(" Exit the interactive mode");
}

View file

@ -0,0 +1,52 @@
[package]
name = "uad-core"
version.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
readme.workspace = true
keywords.workspace = true
categories = ["command-line-utilities"]
edition.workspace = true
[features]
default = []
self-update = ["dep:flate2", "dep:tar"]
[dependencies]
serde.workspace = true
serde_json.workspace = true
log.workspace = true
chrono.workspace = true
toml.workspace = true
dirs.workspace = true
ureq.workspace = true
retry.workspace = true
csv.workspace = true
flate2 = { workspace = true, optional = true }
tar = { workspace = true, optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
win32console.workspace = true
[lints.rust]
deprecated_safe = "warn"
[lints.clippy]
undocumented_unsafe_blocks = "forbid"
exit = "deny"
panic_in_result_fn = "warn"
infinite_loop = "warn"
mem_forget = "warn"
implicit_clone = "warn"
format_push_string = "warn"
large_include_file = "warn"
shadow_unrelated = "warn"
struct_field_names = "allow" # annoying
disallowed_types = "deny"
disallowed_methods = "deny"
allow_attributes_without_reason = "warn"
pedantic = { level = "warn", priority = -1 }

View file

@ -43,16 +43,18 @@ use serde::{Deserialize, Serialize};
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use crate::core::utils::is_all_w_c;
use crate::utils::is_all_w_c;
use log::{error, info};
/// Convert ADB output bytes to a trimmed UTF-8 string.
/// Uses lossy conversion to prevent panics on non-UTF8 output from certain OEMs.
#[must_use]
pub fn to_trimmed_utf8(v: &[u8]) -> String {
String::from_utf8_lossy(v).trim_end().to_string()
}
#[must_use]
#[cfg(debug_assertions)]
#[must_use]
fn is_version_triple(s: &str) -> bool {
let mut components = s.split('.');
for _ in 0..3 {
@ -213,6 +215,12 @@ impl ACommand {
}
}
impl Default for ACommand {
fn default() -> Self {
Self::new()
}
}
/// Builder object for a command that runs on the device's default `sh` implementation.
/// Typically MKSH, but could be Ash.
///
@ -273,6 +281,7 @@ impl PackageId {
/// Creates a package-ID if it's valid according to:
/// - <https://developer.android.com/guide/topics/manifest/manifest-element.html#package>
/// - <https://developer.android.com/build/configure-app-module#set-application-id>
#[must_use]
pub fn new(p_id: Box<str>) -> Option<Self> {
let mut components = p_id.split('.');
for _ in 0..2 {
@ -325,6 +334,7 @@ pub const PM_CLEAR_PACK: &str = "pm clear";
/// Builder object for an Android Package Manager command.
/// <https://developer.android.com/tools/adb#pm>
#[derive(Debug)]
#[must_use]
pub struct PmCommand(ShellCommand);
impl PmCommand {
/// `list packages -s` sub-command, [`PACK_PREFIX`] stripped.

View file

@ -1,13 +1,15 @@
use crate::CACHE_DIR;
use crate::CONFIG_DIR;
use crate::core::utils::DisplayablePath;
use crate::core::{sync::User, theme::Theme};
use crate::gui::views::settings::Settings;
use crate::sync::User;
use crate::utils::DisplayablePath;
use log::error;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::LazyLock;
const DEFAULT_THEME: &str = "Auto (follow system theme)";
#[derive(Default, Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub general: GeneralSettings,
@ -44,7 +46,7 @@ pub struct DeviceSettings {
impl Default for GeneralSettings {
fn default() -> Self {
Self {
theme: Theme::default().to_string(),
theme: DEFAULT_THEME.to_string(),
expert_mode: false,
backup_folder: CACHE_DIR.join("backups"),
}
@ -54,23 +56,26 @@ impl Default for GeneralSettings {
static CONFIG_FILE: LazyLock<PathBuf> = LazyLock::new(|| CONFIG_DIR.join("config.toml"));
impl Config {
pub fn save_changes(settings: &Settings, device_id: &String) {
let mut config = Self::load_configuration_file();
if let Some(device) = config
pub fn save_device_settings(
&mut self,
device_settings: DeviceSettings,
general: GeneralSettings,
) {
if let Some(device) = self
.devices
.iter_mut()
.find(|x| x.device_id == *device_id)
.find(|x| x.device_id == device_settings.device_id)
{
device.clone_from(&settings.device);
*device = device_settings;
} else {
debug!("config: New device settings saved");
config.devices.push(settings.device.clone());
self.devices.push(device_settings);
}
config.general.clone_from(&settings.general);
let toml = toml::to_string(&config).unwrap();
self.general = general;
let toml = toml::to_string(&self).unwrap();
fs::write(&*CONFIG_FILE, toml).expect("Could not write config file to disk!");
}
#[must_use]
pub fn load_configuration_file() -> Self {
match fs::read_to_string(&*CONFIG_FILE) {
Ok(s) => match toml::from_str(&s) {
@ -111,7 +116,7 @@ mod tests {
let config = Config::load_configuration_file();
// non-deterministic
//assert_eq!(config.devices.len(), 0);
assert_eq!(config.general.theme, Theme::default().to_string());
assert_eq!(config.general.theme, DEFAULT_THEME);
assert!(!config.general.expert_mode);
assert_eq!(config.general.backup_folder, CACHE_DIR.join("backups"));
}
@ -133,7 +138,7 @@ mod tests {
fn test_default_config() {
let config = Config::default();
assert_eq!(config.devices.len(), 0);
assert_eq!(config.general.theme, Theme::default().to_string());
assert_eq!(config.general.theme, DEFAULT_THEME);
assert!(!config.general.expert_mode);
assert_eq!(config.general.backup_folder, CACHE_DIR.join("backups"));
}

View file

@ -0,0 +1,23 @@
#![allow(
clippy::missing_panics_doc,
clippy::missing_errors_doc,
clippy::collapsible_if,
clippy::uninlined_format_args,
clippy::result_unit_err,
reason = "Doc+style pedantic lints are out-of-scope for this pass"
)]
pub mod adb;
pub mod config;
pub mod save;
pub mod sync;
pub mod uad_lists;
pub mod update;
pub mod utils;
use std::path::PathBuf;
use std::sync::LazyLock;
pub static CONFIG_DIR: LazyLock<PathBuf> =
LazyLock::new(|| utils::setup_uad_dir(&dirs::config_dir().expect("Can't detect config dir")));
pub static CACHE_DIR: LazyLock<PathBuf> =
LazyLock::new(|| utils::setup_uad_dir(&dirs::cache_dir().expect("Can't detect cache dir")));

View file

@ -1,7 +1,6 @@
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 crate::config::{Config, DeviceSettings};
use crate::sync::{CorePackage, Phone, User, apply_pkg_state_commands};
use crate::utils::DisplayablePath;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use std::{
@ -22,10 +21,10 @@ pub struct UserBackup {
}
/// Backup all `Uninstalled` and `Disabled` packages
pub async fn backup_phone(
pub fn backup_phone(
users: Vec<User>,
device_id: String,
phone_packages: Vec<Vec<PackageRow>>,
phone_packages: &[Vec<CorePackage>],
) -> Result<bool, String> {
let mut backup = PhoneBackup {
device_id: device_id.clone(),
@ -38,11 +37,8 @@ pub async fn backup_phone(
..UserBackup::default()
};
for p in phone_packages[u.index].clone() {
user_backup.packages.push(CorePackage {
name: p.name.clone(),
state: p.state,
});
for p in phone_packages[u.index].iter().cloned() {
user_backup.packages.push(p);
}
backup.users.push(user_backup);
}
@ -79,6 +75,7 @@ pub fn list_available_backups(dir: &Path) -> Vec<DisplayablePath> {
}
}
#[must_use]
pub fn list_available_backup_user(backup: DisplayablePath) -> Vec<User> {
match fs::read_to_string(backup.path) {
Ok(data) => serde_json::from_str::<PhoneBackup>(&data)
@ -113,7 +110,7 @@ pub struct RestoreResult {
pub fn restore_backup(
selected_device: &Phone,
packages: &[Vec<PackageRow>],
packages: &[Vec<CorePackage>],
settings: &DeviceSettings,
) -> Result<RestoreResult, String> {
match fs::read_to_string(
@ -142,7 +139,7 @@ pub fn restore_backup(
.iter()
.find(|x| x.name == backup_package.name)
{
p.into()
p.clone()
} else {
skipped_packages += 1;
warn!(

View file

@ -1,8 +1,8 @@
use crate::core::{
use crate::{
adb::{ACommand as AdbCommand, PM_CLEAR_PACK},
uad_lists::PackageState,
};
use crate::gui::{views::list::PackageInfo, widgets::package_row::PackageRow};
use log::{error, info};
use retry::{OperationResult, delay::Fixed, retry};
use serde::{Deserialize, Serialize};
@ -61,27 +61,25 @@ pub enum AdbError {
/// This replaces the deprecated `adb_shell_command`.
///
/// If `serial` is empty, it lets ADB choose the default device.
pub async fn run_adb_action<S: AsRef<str>>(
pub fn run_adb_shell_action<S: AsRef<str>>(
device_serial: S,
action: String,
p: PackageInfo,
) -> Result<PackageInfo, AdbError> {
action: &str,
) -> Result<String, AdbError> {
let serial = device_serial.as_ref();
let label = &p.removal;
match AdbCommand::new().shell(serial).raw(&action) {
match AdbCommand::new().shell(serial).raw(action) {
Ok(o) => {
if ["Error", "Failure"].iter().any(|&e| o.contains(e)) {
let friendly_msg = make_friendly_error_message(&o, &action);
return Err(AdbError::Generic(format!("[{label}] {friendly_msg}")));
let friendly_msg = make_friendly_error_message(&o, action);
return Err(AdbError::Generic(friendly_msg));
}
info!("[{label}] {action} -> {o}");
Ok(p)
info!("{action} -> {o}");
Ok(o)
}
Err(err) => {
if !err.contains("[not installed for") {
let friendly_msg = make_friendly_error_message(&err, &action);
return Err(AdbError::Generic(format!("[{label}] {friendly_msg}")));
let friendly_msg = make_friendly_error_message(&err, action);
return Err(AdbError::Generic(friendly_msg));
}
Err(AdbError::Generic(err))
}
@ -141,6 +139,7 @@ fn make_friendly_error_message(error_output: &str, action: &str) -> String {
}
/// If `None`, returns an empty String, not " --user 0"
#[must_use]
pub fn user_flag(user_id: Option<User>) -> String {
user_id
.map(|user| format!(" --user {}", user.id))
@ -151,35 +150,16 @@ pub fn user_flag(user_id: Option<User>) -> String {
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct CorePackage {
pub name: String,
pub description: String,
#[serde(default)]
pub removal: crate::uad_lists::Removal,
#[serde(default)]
pub state: PackageState,
#[serde(default)]
pub list: crate::uad_lists::UadList,
}
impl From<&mut PackageRow> for CorePackage {
fn from(pr: &mut PackageRow) -> Self {
Self {
name: pr.name.clone(),
state: pr.state,
}
}
}
impl From<PackageRow> for CorePackage {
fn from(pr: PackageRow) -> Self {
Self {
name: pr.name.clone(),
state: pr.state,
}
}
}
impl From<&PackageRow> for CorePackage {
fn from(pr: &PackageRow) -> Self {
Self {
name: pr.name.clone(),
state: pr.state,
}
}
}
#[must_use]
pub fn apply_pkg_state_commands(
package: &CorePackage,
wanted_state: PackageState,
@ -224,6 +204,7 @@ pub fn apply_pkg_state_commands(
/// Build a command request to be sent via ADB to a device.
/// `commands` accepts one or more ADB shell commands
/// which act on a common `package` and `user`.
#[must_use]
pub fn request_builder(commands: &[&str], package: &str, user: Option<User>) -> Vec<String> {
let maybe_user_flag = user_flag(user);
commands
@ -235,6 +216,7 @@ pub fn request_builder(commands: &[&str], package: &str, user: Option<User>) ->
/// Get the model by querying the `ro.product.model` property.
///
/// If `serial` is empty, it lets ADB choose the default device.
#[must_use]
pub fn get_device_model(serial: &str) -> String {
AdbCommand::new()
.shell(serial)
@ -253,6 +235,7 @@ pub fn get_device_model(serial: &str) -> String {
/// Get the brand by querying the `ro.product.brand` property.
///
/// If `serial` is empty, it lets ADB choose the default device.
#[must_use]
pub fn get_device_brand(serial: &str) -> String {
AdbCommand::new()
.shell(serial)
@ -266,6 +249,7 @@ pub fn get_device_brand(serial: &str) -> String {
// `ro.build.version.sdk` property or defaulting to 0.
///
/// If `device_serial` is empty, it lets ADB choose the default device.
#[must_use]
pub fn get_android_sdk(device_serial: &str) -> u8 {
AdbCommand::new()
.shell(device_serial)
@ -280,6 +264,7 @@ pub fn get_android_sdk(device_serial: &str) -> u8 {
///
/// Only includes users where the package exists (Some state). Users where the package
/// doesn't exist (None) are not tracked.
#[must_use]
pub fn capture_cross_user_states(
package_name: &str,
device_serial: &str,
@ -301,6 +286,7 @@ pub fn capture_cross_user_states(
/// - 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)
#[must_use]
pub fn detect_cross_user_behavior(
package_name: &str,
device_serial: &str,
@ -433,6 +419,7 @@ pub fn is_protected_user<S: AsRef<str>>(user_id: u16, device_serial: S) -> bool
.is_err()
}
#[must_use]
pub fn list_users_idx_prot(device_serial: &str) -> Vec<User> {
AdbCommand::new()
.shell(device_serial)
@ -456,9 +443,10 @@ pub fn list_users_idx_prot(device_serial: &str) -> Vec<User> {
/// This matches serials (`getprop ro.serialno`)
/// that are authorized by the user.
pub async fn get_devices_list() -> Vec<Phone> {
#[must_use]
pub fn get_devices_list() -> Vec<Phone> {
retry(
Fixed::from_millis(500).take(if cfg!(debug_assertions) { 3 } else { 120 }),
Fixed::from_millis(500).take(if cfg!(debug_assertions) { 3 } else { 10 }),
|| match AdbCommand::new().devices() {
Ok(devices) => {
let mut device_list: Vec<Phone> = vec![];
@ -486,20 +474,22 @@ pub async fn get_devices_list() -> Vec<Phone> {
.unwrap_or_default()
}
pub async fn initial_load() -> bool {
#[must_use]
pub fn initial_load() -> bool {
match AdbCommand::new().devices() {
Ok(_devices) => true,
Err(_err) => false,
}
}
/// Verify the actual state of a package on the device
pub fn verify_package_state(
package_name: &str,
/// Get the current state of a package on a device
#[must_use]
pub fn get_package_state(
device_serial: &str,
package_name: &str,
user_id: Option<u16>,
) -> Option<PackageState> {
use crate::core::adb::{ACommand as AdbCommand, PmListPacksFlag};
use crate::adb::{ACommand as AdbCommand, PmListPacksFlag};
// Check if package is enabled
if let Ok(enabled_packages) = AdbCommand::new()
@ -535,11 +525,23 @@ pub fn verify_package_state(
None
}
/// Verify the current state of a package on a device.
/// Returns the package state, or `None` if the package doesn't exist.
#[must_use]
pub fn verify_package_state(
package_name: &str,
device_serial: &str,
user_id: Option<u16>,
) -> Option<PackageState> {
get_package_state(device_serial, package_name, user_id)
}
/// 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).
#[must_use]
pub fn check_cross_user_package_existence(
package_name: &str,
device_serial: &str,
@ -561,9 +563,29 @@ pub fn check_cross_user_package_existence(
other_user_states
}
/// Creates a `CorePackage` with the specified state, preserving other fields from the original.
fn package_with_state(package: &CorePackage, state: PackageState) -> CorePackage {
CorePackage {
state,
..package.clone()
}
}
/// Executes the first available command and returns the result.
fn execute_first_command(commands: &[String], phone: &Phone) -> Result<(), String> {
if commands.is_empty() {
return Err("No command available".to_string());
}
AdbCommand::new()
.shell(&phone.adb_id)
.raw(&commands[0])
.map(|_| ())
.map_err(|err| err.clone())
}
/// Attempt fallback action when package state verification fails
pub fn attempt_fallback(
package: &crate::gui::widgets::package_row::PackageRow,
package: &CorePackage,
wanted_state: PackageState,
actual_state: PackageState,
user: User,
@ -572,101 +594,49 @@ pub fn attempt_fallback(
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}")),
}
}
let pkg = package_with_state(package, PackageState::Enabled);
let commands = apply_pkg_state_commands(&pkg, PackageState::Disabled, user, phone);
execute_first_command(&commands, phone)
.map(|()| "disabled package instead of uninstalling".to_string())
.map_err(|e| format!("Failed to disable package: {e}"))
}
// 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);
let pkg = package_with_state(package, PackageState::Enabled);
let commands = apply_pkg_state_commands(&pkg, PackageState::Uninstalled, user, phone);
execute_first_command(&commands, phone)
.map_err(|e| format!("Failed to uninstall: {e}"))?;
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}")),
// 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()),
}
}
// 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);
let pkg = package_with_state(package, PackageState::Disabled);
let uninstall_cmds =
apply_pkg_state_commands(&pkg, PackageState::Uninstalled, user, phone);
execute_first_command(&uninstall_cmds, phone)
.map_err(|e| format!("Failed to uninstall for reinstall: {e}"))?;
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,
);
// Now try to reinstall/enable
let pkg_uninstalled = package_with_state(package, PackageState::Uninstalled);
let enable_cmds =
apply_pkg_state_commands(&pkg_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}")),
}
if enable_cmds.is_empty() {
return Ok("uninstalled package but couldn't reinstall".to_string());
}
execute_first_command(&enable_cmds, phone)
.map(|()| "uninstalled and reinstalled package to enable it".to_string())
.map_err(|e| format!("Failed to reinstall package: {e}"))
}
// Other cases - no fallback available

View file

@ -1,5 +1,6 @@
use crate::CACHE_DIR;
use crate::core::utils::{format_diff_time_from_now, last_modified_date};
use crate::utils::{format_diff_time_from_now, last_modified_date};
use log::warn;
use retry::{OperationResult, delay::Fixed, retry};
use serde::{Deserialize, Serialize};
use serde_json;
@ -15,7 +16,7 @@ pub const LIST_FNAME: &str = "uad_lists.json";
reason = "https://github.com/Universal-Debloater-Alliance/universal-android-debloater-next-generation/discussions/608"
)]
// not `const`, because it's too big
pub static DATA: &str = include_str!("../../resources/assets/uad_lists.json");
pub static DATA: &str = include_str!("../../../resources/assets/uad_lists.json");
#[derive(Deserialize, Debug, Clone, PartialEq, Hash, Eq)]
#[serde(rename_all = "camelCase")]
@ -28,7 +29,7 @@ pub struct Package {
pub removal: Removal,
}
#[derive(Default, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Default, Deserialize, Serialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UadList {
#[default]
All,
@ -73,6 +74,7 @@ impl UadList {
Self::Unlisted,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::All => "All lists",
@ -148,7 +150,7 @@ impl Opposite for PackageState {
}
// Bad names. To be changed!
#[derive(Default, Debug, Deserialize, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Default, Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Removal {
#[default]
Recommended,
@ -176,6 +178,7 @@ impl Removal {
Self::Unlisted,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::All => "All removals",

View file

@ -1,9 +1,9 @@
use crate::core::utils::NAME;
use serde::Deserialize;
#[cfg(feature = "self-update")]
use {
crate::utils::NAME,
log::{debug, error},
retry::{OperationResult, delay::Fibonacci, retry},
std::fs,
std::io,

View file

@ -1,14 +1,13 @@
#![warn(clippy::unwrap_used)]
use crate::core::{
use crate::{
adb::{ACommand as AdbCommand, PmListPacksFlag},
sync::User,
theme::Theme,
sync::{CorePackage, User},
uad_lists::{PackageHashMap, PackageState, Removal, UadList},
};
use crate::gui::widgets::package_row::PackageRow;
use chrono::{DateTime, offset::Utc};
use csv::Writer;
use log::error;
use std::{
collections::HashSet,
fmt, fs,
@ -66,11 +65,12 @@ pub enum Error {
DialogClosed,
}
#[must_use]
pub fn fetch_packages(
uad_lists: &PackageHashMap,
device_serial: &str,
user_id: Option<u16>,
) -> Vec<PackageRow> {
) -> Vec<CorePackage> {
let all_sys_packs = AdbCommand::new()
.shell(device_serial)
.pm()
@ -92,24 +92,24 @@ pub fn fetch_packages(
.collect();
let mut description;
let mut uad_list;
let mut state;
let mut removal;
let mut user_package: Vec<PackageRow> = Vec::new();
let mut state;
let mut list;
let mut user_package: Vec<CorePackage> = Vec::new();
for pack_name in all_sys_packs {
let p_name = &pack_name;
state = PackageState::Uninstalled;
description = "[No description]: CONTRIBUTION WELCOMED";
uad_list = UadList::Unlisted;
description = String::from("[No description]: CONTRIBUTION WELCOMED");
removal = Removal::Unlisted;
state = PackageState::Uninstalled;
list = UadList::Unlisted;
if let Some(package) = uad_lists.get(p_name) {
if !package.description.is_empty() {
description = &package.description;
description = package.description.clone();
}
uad_list = package.list;
removal = package.removal;
list = package.list;
}
if enabled_sys_packs.contains(p_name) {
@ -118,25 +118,20 @@ pub fn fetch_packages(
state = PackageState::Disabled;
}
let package_row =
PackageRow::new(p_name, state, description, uad_list, removal, false, false);
user_package.push(package_row);
let package = CorePackage {
name: p_name.clone(),
description,
removal,
state,
list,
};
user_package.push(package);
}
user_package.sort_by_key(|a| a.name.to_lowercase());
user_package.sort_by_key(|package| package.name.to_lowercase());
user_package
}
pub fn string_to_theme(theme: &str) -> Theme {
match theme {
"Dark" => Theme::Dark,
"Light" => Theme::Light,
"Lupin" => Theme::Lupin,
// Auto uses `Display`, so it doesn't have a canonical repr
t if t.starts_with("Auto") => Theme::Auto,
_ => Theme::default(),
}
}
#[must_use]
pub fn setup_uad_dir(dir: &Path) -> PathBuf {
let dir = dir.join("uad");
if let Err(e) = fs::create_dir_all(&dir) {
@ -173,6 +168,7 @@ pub fn open_url(dir: PathBuf) {
}
#[rustfmt::skip]
#[must_use]
pub fn last_modified_date(file: PathBuf) -> DateTime<Utc> {
fs::metadata(file).map_or_else(|_| Utc::now(), |metadata| match metadata.modified() {
Ok(time) => time.into(),
@ -180,6 +176,7 @@ pub fn last_modified_date(file: PathBuf) -> DateTime<Utc> {
})
}
#[must_use]
pub fn format_diff_time_from_now(date: DateTime<Utc>) -> String {
let now: DateTime<Utc> = Utc::now();
let last_update = now - date;
@ -194,15 +191,10 @@ pub fn format_diff_time_from_now(date: DateTime<Utc>) -> String {
}
}
/// Export selected packages.
/// Export selected package names.
/// File will be saved in same directory where UAD-ng is located.
pub async fn export_selection(packages: Vec<PackageRow>) -> Result<bool, String> {
let selected = packages
.iter()
.filter(|p| p.selected)
.map(|p| p.name.clone())
.collect::<Vec<String>>()
.join("\n");
pub fn export_selection(package_names: &[String]) -> Result<bool, String> {
let selected = package_names.join("\n");
match fs::write(EXPORT_FILE_NAME, selected) {
Ok(()) => Ok(true),
@ -236,22 +228,9 @@ impl fmt::Display for DisplayablePath {
}
}
/// Can be used to choose any folder.
pub async fn open_folder() -> Result<PathBuf, Error> {
let picked_folder = rfd::AsyncFileDialog::new()
.pick_folder()
.await
.ok_or(Error::DialogClosed)?;
Ok(picked_folder.path().to_owned())
}
/// Export uninstalled packages in a csv file.
/// Exported information will contain package name and description.
pub async fn export_packages(
user: User,
phone_packages: Vec<Vec<PackageRow>>,
) -> Result<bool, String> {
pub fn export_packages(user: User, phone_packages: &[Vec<CorePackage>]) -> Result<bool, String> {
let backup_file = generate_backup_name(chrono::Local::now());
let file = fs::File::create(backup_file).map_err(|err| err.to_string())?;
@ -260,7 +239,7 @@ pub async fn export_packages(
wtr.write_record(["Package Name", "Description"])
.map_err(|err| err.to_string())?;
let uninstalled_packages: Vec<&PackageRow> = phone_packages[user.index]
let uninstalled_packages: Vec<&CorePackage> = phone_packages[user.index]
.iter()
.filter(|p| p.state == PackageState::Uninstalled)
.collect();
@ -275,6 +254,36 @@ pub async fn export_packages(
Ok(true)
}
/// Truncate description to fit within max length, taking only the first line
#[must_use]
pub fn truncate_description(desc: &str, max_len: usize) -> String {
let first_line = desc.split('\n').next().unwrap_or("").trim_end();
if first_line.len() <= max_len {
first_line.to_string()
} else {
format!("{}...", &first_line[..max_len.saturating_sub(3)])
}
}
/// Check if package matches search term (checks name and description)
#[must_use]
pub fn matches_search(pkg_name: &str, search_term: &str, pkg_description: Option<&str>) -> bool {
let search_lower = search_term.to_lowercase();
if pkg_name.to_lowercase().contains(&search_lower) {
return true;
}
if let Some(description) = pkg_description {
if description.to_lowercase().contains(&search_lower) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;

62
crates/uad-gui/Cargo.toml Normal file
View file

@ -0,0 +1,62 @@
[package]
name = "uad-gui"
version.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
readme.workspace = true
keywords.workspace = true
categories = ["gui"]
edition.workspace = true
[[bin]]
name = "uad-ng"
path = "src/main.rs"
[features]
default = ["wgpu", "self-update", "img"]
wgpu = [] # Iced/wgpu is default
self-update = ["flate2", "tar"]
no-self-update = []
img = ["image", "iced/image"]
[dependencies]
uad-core = { path = "../uad-core", features = ["self-update"] }
iced.workspace = true
image = { workspace = true, optional = true }
rfd.workspace = true
dark-light.workspace = true
fern.workspace = true
chrono.workspace = true
dirs.workspace = true
log.workspace = true
flate2 = { workspace = true, optional = true }
tar = { workspace = true, optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
win32console.workspace = true
[build-dependencies]
embed-resource = "3"
[lints.rust]
deprecated_safe = "warn"
[lints.clippy]
undocumented_unsafe_blocks = "forbid"
exit = "deny"
panic_in_result_fn = "warn"
infinite_loop = "warn"
mem_forget = "warn"
implicit_clone = "warn"
format_push_string = "warn"
large_include_file = "warn"
shadow_unrelated = "warn"
struct_field_names = "allow" # annoying
disallowed_types = "deny"
disallowed_methods = "deny"
allow_attributes_without_reason = "warn"
pedantic = { level = "warn", priority = -1 }

View file

@ -1,35 +1,32 @@
pub mod style;
pub mod views;
pub mod widgets;
use crate::core::adb;
use crate::core::sync::{Phone, get_devices_list, initial_load};
#[cfg(feature = "img")]
use crate::core::theme::OS_COLOR_SCHEME;
use crate::core::theme::Theme;
use crate::core::uad_lists::UadListState;
use crate::core::update::{Release, SelfUpdateState, SelfUpdateStatus, get_latest_release};
use crate::core::utils::{FULL_NAME, NAME, string_to_theme};
use crate::theme::OS_COLOR_SCHEME;
use crate::theme::Theme;
use crate::theme::string_to_theme;
use crate::views::about::{About as AboutView, Message as AboutMessage};
use crate::views::list::{
List as AppsView, LoadingState as ListLoadingState, Message as AppsMessage,
};
use crate::views::settings::{Message as SettingsMessage, Settings as SettingsView};
use crate::widgets::navigation_menu::nav_menu;
use iced::font;
use iced::widget::column;
#[cfg(feature = "img")]
use iced::window::icon;
#[cfg(feature = "img")]
use image::ImageFormat;
use views::about::{About as AboutView, Message as AboutMessage};
use views::list::{List as AppsView, LoadingState as ListLoadingState, Message as AppsMessage};
use views::settings::{Message as SettingsMessage, Settings as SettingsView};
use widgets::navigation_menu::nav_menu;
use iced::widget::column;
use iced::{Alignment, Element, Length, Settings, Task, window::Settings as Window};
use iced::{Subscription, event, keyboard};
#[cfg(feature = "img")]
use image::ImageFormat;
use log::{debug, error, info};
#[cfg(feature = "self-update")]
use std::path::PathBuf;
use uad_core::adb;
use uad_core::sync::{Phone, get_devices_list, initial_load};
use uad_core::uad_lists::UadListState;
use uad_core::update::{Release, SelfUpdateState, SelfUpdateStatus, get_latest_release};
use uad_core::utils::{FULL_NAME, NAME};
#[cfg(feature = "self-update")]
use crate::core::update::{BIN_NAME, download_update_to_temp_file, remove_file};
use uad_core::update::{BIN_NAME, download_update_to_temp_file, remove_file};
#[derive(Default, Debug, Clone)]
enum View {
@ -41,8 +38,8 @@ enum View {
#[derive(Default, Clone)]
pub struct UpdateState {
self_update: SelfUpdateState,
uad_list: UadListState,
pub self_update: SelfUpdateState,
pub uad_list: UadListState,
}
#[derive(Default)]
@ -86,18 +83,21 @@ impl UadGui {
reason = "required by iced's Application trait interface"
)]
fn title(&self) -> String {
FULL_NAME.to_string()
self.selected_device.as_ref().map_or_else(
|| FULL_NAME.to_string(),
|device| format!("{FULL_NAME} - {}", device.model),
)
}
fn new() -> (Self, Task<Message>) {
(
Self::default(),
Task::batch([
// Used in crate::gui::widgets::navigation_menu::ICONS. Name is `icomoon`.
font::load(include_bytes!("../../resources/assets/icons.ttf").as_slice())
// Used in crate::widgets::navigation_menu::ICONS. Name is `icomoon`.
font::load(include_bytes!("../../../resources/assets/icons.ttf").as_slice())
.map(Message::FontLoaded),
Task::perform(initial_load(), Message::ADBSatisfied),
Task::perform(get_devices_list(), Message::LoadDevices),
Task::perform(async { initial_load() }, Message::ADBSatisfied),
Task::perform(async { get_devices_list() }, Message::LoadDevices),
Task::perform(
async move { get_latest_release() },
Message::GetLatestRelease,
@ -111,21 +111,45 @@ impl UadGui {
reason = "required by iced's Application trait interface"
)]
fn subscription(&self) -> Subscription<Message> {
event::listen_with(|event, _status, _env| match event {
if self.selected_device.is_some() {
event::listen_with(Self::keyboard_shortcuts_with_device)
} else {
event::listen_with(Self::keyboard_shortcuts_without_device)
}
}
fn keyboard_shortcuts_with_device(
event: iced::Event,
_status: iced::event::Status,
_window: iced::window::Id,
) -> Option<Message> {
Self::keyboard_shortcuts(event, true)
}
fn keyboard_shortcuts_without_device(
event: iced::Event,
_status: iced::event::Status,
_window: iced::window::Id,
) -> Option<Message> {
Self::keyboard_shortcuts(event, false)
}
fn keyboard_shortcuts(event: iced::Event, can_control_device: bool) -> Option<Message> {
match event {
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(c),
modifiers,
..
}) if modifiers.control() && modifiers.shift() => match c.as_str() {
"r" => Some(Message::RebootButtonPressed),
"5" => Some(Message::RefreshButtonPressed),
"r" if can_control_device => Some(Message::RebootButtonPressed),
"5" if can_control_device => Some(Message::RefreshButtonPressed),
"a" => Some(Message::AppsPress),
"i" => Some(Message::AboutPressed),
"s" => Some(Message::SettingsPressed),
_ => None,
},
_ => None,
})
}
}
#[allow(
@ -178,7 +202,7 @@ impl UadGui {
self.adb_satisfied,
)));
}
Task::perform(get_devices_list(), Message::LoadDevices)
Task::perform(async { get_devices_list() }, Message::LoadDevices)
}
Message::RebootButtonPressed => {
self.apps_view = AppsView::default();
@ -410,12 +434,12 @@ impl UadGui {
pub fn start() -> iced::Result {
#[cfg(feature = "img")]
let logo: &[u8] = match *OS_COLOR_SCHEME {
// remember to keep `Unspecified` in sync with `src/core/theme`
// remember to keep `Unspecified` in sync with `src/theme`
dark_light::Mode::Dark | dark_light::Mode::Unspecified => {
include_bytes!("../../resources/assets/logo-dark.png")
include_bytes!("../../../resources/assets/logo-dark.png")
}
dark_light::Mode::Light => {
include_bytes!("../../resources/assets/logo-light.png")
include_bytes!("../../../resources/assets/logo-light.png")
}
};

View file

@ -0,0 +1,23 @@
use crate::style;
use crate::theme::Theme;
use iced::Element;
use iced::widget::button::Button;
/// Wrapper function for `iced::widget::button` with padding and style applied
pub fn button_primary<'a, Message>(
content: impl Into<Element<'a, Message, Theme>>,
) -> Button<'a, Message, Theme> {
iced::widget::button(content)
.padding([5, 10])
.style(style::Button::Primary)
}
/// Opens a file picker dialog to select a folder
pub async fn open_folder() -> Result<std::path::PathBuf, uad_core::utils::Error> {
rfd::AsyncFileDialog::new()
.set_title("Choose a backup location")
.pick_folder()
.await
.ok_or(uad_core::utils::Error::DialogClosed)
.map(|f| f.path().to_path_buf())
}

11
crates/uad-gui/src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
#![allow(
clippy::missing_errors_doc,
reason = "Short-circuit doc lint to keep CI green"
)]
pub mod gui;
pub mod helpers;
pub mod style;
pub mod theme;
pub mod views;
pub mod widgets;

View file

@ -1,8 +1,5 @@
#![windows_subsystem = "windows"]
#[macro_use]
extern crate log;
use crate::core::utils::setup_uad_dir;
use fern::{
FormatCallback,
colors::{Color, ColoredLevelConfig},
@ -10,12 +7,10 @@ use fern::{
use log::Record;
use std::sync::LazyLock;
use std::{fmt::Arguments, fs::OpenOptions, path::PathBuf};
use uad_core::utils::setup_uad_dir;
mod core;
mod gui;
use uad_gui::gui::UadGui;
static CONFIG_DIR: LazyLock<PathBuf> =
LazyLock::new(|| setup_uad_dir(&dirs::config_dir().expect("Can't detect config dir")));
static CACHE_DIR: LazyLock<PathBuf> =
LazyLock::new(|| setup_uad_dir(&dirs::cache_dir().expect("Can't detect cache dir")));
@ -30,7 +25,7 @@ fn main() -> iced::Result {
}
setup_logger().expect("setup logging");
gui::UadGui::start()
UadGui::start()
}
/// Sets up logging to a new file in `CACHE_DIR"/uadng.log"`
@ -74,14 +69,14 @@ fn setup_logger() -> Result<(), fern::InitError> {
.format(make_formatter(false))
.level(default_log_level)
// Rust compiler makes module names use _ instead of -
.level_for("uad_ng", log::LevelFilter::Debug)
.level_for("uad_gui", log::LevelFilter::Debug)
.chain(log_file);
let stdout_dispatcher = fern::Dispatch::new()
.format(make_formatter(true))
.level(default_log_level)
// Rust compiler makes module names use _ instead of -
.level_for("uad_ng", log::LevelFilter::Warn)
.level_for("uad_gui", log::LevelFilter::Warn)
.chain(std::io::stdout());
fern::Dispatch::new()

View file

@ -4,7 +4,7 @@
clippy::wildcard_imports,
reason = "Iced style modules use PascalCase and &Theme; wildcard for local convenience"
)]
use crate::core::theme::{ColorPalette, Theme};
use crate::theme::{ColorPalette, Theme};
use iced::widget::{
button, checkbox, container, overlay, pick_list, radio, scrollable, text, text_editor,
text_input,
@ -321,10 +321,12 @@ pub mod Container {
use super::*;
#[allow(dead_code, reason = "Used by other themes or future styles")]
#[must_use]
pub fn Invisible(_: &Theme) -> container::Style {
container::Style::default()
}
#[must_use]
pub fn Frame(theme: &Theme) -> container::Style {
let p = theme.palette();
container::Style {
@ -340,6 +342,7 @@ pub mod Container {
}
}
#[must_use]
pub fn BorderedFrame(theme: &Theme) -> container::Style {
let p = theme.palette();
container::Style {
@ -356,6 +359,7 @@ pub mod Container {
}
#[allow(dead_code, reason = "Currently unused in some views")]
#[must_use]
pub fn Tooltip(theme: &Theme) -> container::Style {
let p = theme.palette();
container::Style {
@ -371,6 +375,7 @@ pub mod Container {
}
}
#[must_use]
pub fn Background(theme: &Theme) -> container::Style {
let p = theme.palette();
container::Style {
@ -408,6 +413,7 @@ pub mod Button {
}
}
#[must_use]
pub fn Primary(theme: &Theme, status: button::Status) -> button::Style {
let p = theme.palette();
let mut style = style_active_hover_disabled(p.bright.primary, p.bright.primary, status);
@ -421,10 +427,12 @@ pub mod Button {
dead_code,
reason = "Alias kept for semantic clarity in some call-sites"
)]
#[must_use]
pub fn SelfUpdate(theme: &Theme, status: button::Status) -> button::Style {
Primary(theme, status)
}
#[must_use]
pub fn RestorePackage(theme: &Theme, status: button::Status) -> button::Style {
let p = theme.palette();
let mut style = style_active_hover_disabled(p.bright.secondary, p.bright.secondary, status);
@ -441,6 +449,7 @@ pub mod Button {
style
}
#[must_use]
pub fn UninstallPackage(theme: &Theme, status: button::Status) -> button::Style {
let p = theme.palette();
let mut style = style_active_hover_disabled(p.bright.error, p.bright.error, status);
@ -454,10 +463,12 @@ pub mod Button {
dead_code,
reason = "Style exposed for disabled state buttons in some contexts"
)]
#[must_use]
pub fn Unavailable(theme: &Theme, status: button::Status) -> button::Style {
UninstallPackage(theme, status)
}
#[must_use]
pub fn NormalPackage(theme: &Theme, status: button::Status) -> button::Style {
let p = theme.palette();
match status {
@ -489,6 +500,7 @@ pub mod Button {
}
}
#[must_use]
pub fn SelectedPackage(theme: &Theme, _status: button::Status) -> button::Style {
let p = theme.palette();
button::Style {
@ -508,6 +520,7 @@ pub mod Button {
}
#[allow(dead_code, reason = "Used in views where buttons must be invisible")]
#[must_use]
pub fn Hidden(_: &Theme, _: button::Status) -> button::Style {
button::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
@ -605,6 +618,7 @@ pub mod Scrollable {
}
}
#[must_use]
pub fn Description(theme: &Theme, _status: scrollable::Status) -> scrollable::Style {
let p = theme.palette();
let (v, h) = rails(p.normal.surface);
@ -617,6 +631,7 @@ pub mod Scrollable {
}
}
#[must_use]
pub fn Packages(theme: &Theme, _status: scrollable::Status) -> scrollable::Style {
let p = theme.palette();
let (v, h) = rails(p.base.foreground);
@ -633,6 +648,7 @@ pub mod Scrollable {
pub mod CheckBox {
use super::*;
#[must_use]
pub fn PackageEnabled(theme: &Theme, _status: checkbox::Status) -> checkbox::Style {
let p = theme.palette();
checkbox::Style {
@ -647,6 +663,7 @@ pub mod CheckBox {
}
}
#[must_use]
pub fn PackageDisabled(theme: &Theme, _status: checkbox::Status) -> checkbox::Style {
let p = theme.palette();
checkbox::Style {
@ -664,6 +681,7 @@ pub mod CheckBox {
}
}
#[must_use]
pub fn SettingsEnabled(theme: &Theme, _status: checkbox::Status) -> checkbox::Style {
let p = theme.palette();
checkbox::Style {
@ -678,6 +696,7 @@ pub mod CheckBox {
}
}
#[must_use]
pub fn SettingsDisabled(theme: &Theme, _status: checkbox::Status) -> checkbox::Style {
let p = theme.palette();
checkbox::Style {
@ -696,11 +715,13 @@ pub mod CheckBox {
pub mod Text {
use super::*;
#[must_use]
pub fn Default(theme: &Theme) -> text::Style {
let _ = theme;
text::Style::default()
}
#[must_use]
pub fn Ok(theme: &Theme) -> text::Style {
let p = theme.palette();
text::Style {
@ -708,6 +729,7 @@ pub mod Text {
}
}
#[must_use]
pub fn Danger(theme: &Theme) -> text::Style {
let p = theme.palette();
text::Style {
@ -715,6 +737,7 @@ pub mod Text {
}
}
#[must_use]
pub fn Commentary(theme: &Theme) -> text::Style {
let p = theme.palette();
text::Style {
@ -738,7 +761,7 @@ mod tests {
#[test]
fn test_palette() {
let palette = Theme::default().palette();
let palette = Theme::Dark.palette();
assert_ne!(palette.base.background, palette.base.foreground);
assert_ne!(palette.normal.primary, Color::BLACK);

View file

@ -1,4 +1,3 @@
use dark_light;
use iced::theme::{self, Mode, Palette, Style};
use iced::{Color, color};
use std::sync::LazyLock;
@ -151,6 +150,17 @@ impl std::fmt::Display for Theme {
}
}
/// Converts a string to the GUI's Theme type
#[must_use]
pub fn string_to_theme(theme: &str) -> Theme {
match theme {
"Lupin" => Theme::Lupin,
"Dark" => Theme::Dark,
"Light" => Theme::Light,
_ => Theme::Auto,
}
}
impl theme::Base for Theme {
fn default(preference: Mode) -> Self {
match preference {

View file

@ -1,16 +1,17 @@
use crate::CACHE_DIR;
use crate::core::adb;
use crate::core::helpers::button_primary;
use crate::core::theme::Theme;
use crate::core::uad_lists::LIST_FNAME;
use crate::core::utils::{FULL_NAME, NAME, last_modified_date, open_url};
use crate::gui::{UpdateState, style, widgets::text};
use crate::helpers::button_primary;
use crate::theme::Theme;
use crate::{gui::UpdateState, style, widgets::text};
use iced::widget::{Space, column, container, row};
use iced::{Alignment, Element, Length, Renderer};
use log::error;
use std::path::PathBuf;
use uad_core::CACHE_DIR;
use uad_core::adb;
use uad_core::uad_lists::LIST_FNAME;
use uad_core::utils::{FULL_NAME, NAME, last_modified_date, open_url};
#[cfg(feature = "self-update")]
use crate::core::update::SelfUpdateStatus;
use uad_core::update::SelfUpdateStatus;
#[derive(Default, Debug, Clone)]
pub struct About {}

View file

@ -1,19 +1,22 @@
use crate::core::config::DeviceSettings;
use crate::core::helpers::button_primary;
use crate::core::sync::{AdbError, Phone, User, apply_pkg_state_commands, run_adb_action};
use crate::core::theme::Theme;
use crate::core::uad_lists::{
use crate::helpers::button_primary;
use crate::style;
use crate::theme::Theme;
use crate::widgets::navigation_menu::ICONS;
use log::{error, info, warn};
use std::path::PathBuf;
use uad_core::config::DeviceSettings;
use uad_core::sync::{AdbError, Phone, User, apply_pkg_state_commands};
use uad_core::uad_lists::{
Opposite, PackageHashMap, PackageState, Removal, UadList, UadListState, load_debloat_lists,
};
use crate::core::utils::{EXPORT_FILE_NAME, NAME, export_selection, fetch_packages, open_url};
use crate::gui::style;
use crate::gui::widgets::navigation_menu::ICONS;
use std::path::PathBuf;
use uad_core::utils::{
EXPORT_FILE_NAME, NAME, export_selection, fetch_packages, matches_search, open_url,
};
use crate::gui::views::settings::Settings;
use crate::gui::widgets::modal::Modal;
use crate::gui::widgets::package_row::{Message as RowMessage, PackageRow};
use crate::gui::widgets::text;
use crate::views::settings::Settings;
use crate::widgets::modal::Modal;
use crate::widgets::package_row::{Message as RowMessage, PackageRow};
use crate::widgets::text;
use iced::widget::scrollable::{Direction, Scrollbar};
use iced::widget::{
Column, Space, button, checkbox, column, container, pick_list, radio, row, rule, scrollable,
@ -52,8 +55,8 @@ pub struct List {
filtered_packages: Vec<usize>,
/// Vec of `(user_index, pkg_index)`
selected_packages: Vec<(usize, usize)>,
selected_package_state: Option<PackageState>,
selected_removal: Option<Removal>,
selected_package_state: Option<PackageState>,
selected_list: Option<UadList>,
pub selected_user: Option<User>,
all_selected: bool,
@ -81,8 +84,8 @@ pub enum Message {
ToggleAllSelected(bool),
ListSelected(UadList),
UserSelected(User),
PackageStateSelected(PackageState),
RemovalSelected(Removal),
PackageStateSelected(PackageState),
ApplyActionOnSelection,
List(usize, RowMessage),
VerifyAndFallback(Result<PackageInfo, AdbError>),
@ -149,8 +152,8 @@ impl List {
}
Message::SearchInputChanged(letter) => self.on_search_input_changed(letter),
Message::ListSelected(list) => self.on_list_selected(list),
Message::PackageStateSelected(state) => self.on_package_state_selected(state),
Message::RemovalSelected(removal) => self.on_removal_selected(removal),
Message::PackageStateSelected(state) => self.on_package_state_selected(state),
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),
@ -635,7 +638,7 @@ impl List {
.width(120),
row![text(
self.phone_packages[selection.0][selection.1]
.uad_list
.list
.to_string()
)]
.width(55),
@ -752,12 +755,11 @@ impl List {
// that's why `enumerate` is before `filter`.
.enumerate()
.filter(|(_, p)| {
(list_filter == UadList::All || p.uad_list == list_filter)
(list_filter == UadList::All || p.list == list_filter)
&& (package_filter == PackageState::All || p.state == package_filter)
&& (removal_filter == Removal::All || p.removal == removal_filter)
&& (self.input_value.is_empty()
|| p.name.contains(&self.input_value)
|| p.description.contains(&self.input_value))
|| matches_search(&p.name, &self.input_value, Some(&p.description)))
})
.map(|(i, _)| i)
.collect();
@ -771,11 +773,21 @@ impl List {
) -> Vec<Vec<PackageRow>> {
let serial = device_serial.as_ref();
if user_list.len() <= 1 {
vec![fetch_packages(&uad_list, serial, None)]
vec![
fetch_packages(&uad_list, serial, None)
.into_iter()
.map(PackageRow::from)
.collect(),
]
} else {
user_list
.iter()
.map(|user| fetch_packages(&uad_list, serial, Some(user.id)))
.map(|user| {
fetch_packages(&uad_list, serial, Some(user.id))
.into_iter()
.map(PackageRow::from)
.collect()
})
.collect()
}
}
@ -877,8 +889,8 @@ impl List {
let i_user = self.selected_user.unwrap_or_default().index;
self.phone_packages = packages;
self.filtered_packages = (0..self.phone_packages[i_user].len()).collect();
self.selected_package_state = Some(PackageState::Enabled);
self.selected_removal = Some(Removal::Recommended);
self.selected_package_state = Some(PackageState::Enabled);
self.selected_list = Some(UadList::All);
self.selected_user = Some(User::default());
self.fallback_notifications.clear();
@ -1053,7 +1065,7 @@ impl List {
Task::perform(
async move {
// Blocking ADB calls happen here (off UI thread)
let actual_state_opt = crate::core::sync::verify_package_state(
let actual_state_opt = uad_core::sync::verify_package_state(
&pkg_name,
device.adb_id.as_str(),
Some(user_id),
@ -1062,7 +1074,7 @@ impl List {
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(
let error_modal = uad_core::sync::detect_cross_user_behavior(
&pkg_name,
device.adb_id.as_str(),
user_id,
@ -1091,16 +1103,16 @@ impl List {
// 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,
),
// Create CorePackage for fallback
let core_package = uad_core::sync::CorePackage {
name: pkg_name.clone(),
description: String::new(),
removal: Removal::All,
state: current_state,
list: UadList::Unlisted,
};
let fallback_result = uad_core::sync::attempt_fallback(
&core_package,
wanted_state,
actual_state,
device.user_list[i_user],
@ -1195,14 +1207,17 @@ impl List {
// fallback task
return Task::perform(
async move {
let temp_pkg = crate::core::sync::CorePackage {
let temp_pkg = uad_core::sync::CorePackage {
name: pkg_name.clone(),
description: String::new(),
removal: Removal::All,
state: PackageState::Disabled,
list: UadList::Unlisted,
};
// get uninstall command
let uninstall_cmds =
crate::core::sync::apply_pkg_state_commands(
uad_core::sync::apply_pkg_state_commands(
&temp_pkg,
PackageState::Uninstalled,
user,
@ -1210,24 +1225,24 @@ impl List {
);
if let Some(cmd) = uninstall_cmds.first() {
let dummy_info = PackageInfo::default();
let _ = crate::core::sync::run_adb_action(
device.adb_id.clone(),
cmd.clone(),
dummy_info,
)
.await;
let _ = uad_core::sync::run_adb_shell_action(
&device.adb_id,
cmd,
);
}
// reinstall/enable
let temp_pkg_uninstalled =
crate::core::sync::CorePackage {
uad_core::sync::CorePackage {
name: pkg_name.clone(),
description: String::new(),
removal: Removal::All,
state: PackageState::Uninstalled,
list: UadList::Unlisted,
};
let enable_cmds =
crate::core::sync::apply_pkg_state_commands(
uad_core::sync::apply_pkg_state_commands(
&temp_pkg_uninstalled,
PackageState::Enabled,
user,
@ -1236,18 +1251,15 @@ impl List {
// lets enable the new package
if let Some(cmd) = enable_cmds.first() {
let dummy_info = PackageInfo::default();
let _ = crate::core::sync::run_adb_action(
device.adb_id.clone(),
cmd.clone(),
dummy_info,
)
.await;
let _ = uad_core::sync::run_adb_shell_action(
&device.adb_id,
cmd,
);
}
//verifies the final state for thread confirmation
let actual_state =
crate::core::sync::verify_package_state(
uad_core::sync::verify_package_state(
&pkg_name,
&device.adb_id,
Some(user.id),
@ -1316,8 +1328,13 @@ impl List {
fn on_export_selection(&mut self) -> Task<Message> {
let i_user = self.selected_user.unwrap_or_default().index;
let package_names: Vec<String> = self.phone_packages[i_user]
.iter()
.filter(|p| p.selected)
.map(|p| p.name.clone())
.collect();
Task::perform(
export_selection(self.phone_packages[i_user].clone()),
async move { export_selection(&package_names) },
Message::SelectionExported,
)
}
@ -1483,17 +1500,24 @@ fn build_action_pkg_commands(
// Will be filled asynchronously before running the adb action
before_cross_user_states: vec![],
};
// Clone data before async block to avoid borrowing issues
let device_serial = device.adb_id.clone();
let package_name = u_pkg.name.clone();
let user_id = u.id;
let phone = device.clone();
// In the end there is only one package state change
// even if we run multiple adb commands
commands.push(Task::perform(
run_adb_action_with_before_states(
device.adb_id.clone(),
action,
p_info,
u_pkg.name.clone(),
u.id,
device.clone(),
),
async move {
run_adb_action_with_before_states(
&device_serial,
&action,
p_info,
&package_name,
user_id,
&phone,
)
},
if j == 0 {
Message::VerifyAndFallback
} else {
@ -1505,23 +1529,25 @@ fn build_action_pkg_commands(
commands
}
async fn run_adb_action_with_before_states(
device_serial: String,
action: String,
fn run_adb_action_with_before_states(
device_serial: &str,
action: &str,
mut p_info: PackageInfo,
package_name: String,
package_name: &str,
target_user_id: u16,
phone: Phone,
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,
let before_states = uad_core::sync::capture_cross_user_states(
package_name,
device_serial,
target_user_id,
&phone,
phone,
);
p_info.before_cross_user_states = before_states;
run_adb_action(device_serial, action, p_info).await
// Run the ADB action
uad_core::sync::run_adb_shell_action(device_serial, action)?;
Ok(p_info)
}
fn recap<'a>(settings: &Settings, recap: &SummaryEntry) -> Element<'a, Message, Theme, Renderer> {

View file

@ -1,15 +1,7 @@
use crate::core::{
config::{BackupSettings, Config, DeviceSettings, GeneralSettings},
helpers::button_primary,
save::{backup_phone, list_available_backup_user, list_available_backups, restore_backup},
sync::{AdbError, Phone, User, get_android_sdk, run_adb_action, supports_multi_user},
theme::Theme,
utils::{
DisplayablePath, Error, NAME, export_packages, generate_backup_name, open_folder, open_url,
string_to_theme,
},
};
use crate::gui::{
use crate::helpers::{button_primary, open_folder};
use crate::theme::Theme;
use crate::theme::string_to_theme;
use crate::{
style,
views::list::{List as AppsView, PackageInfo},
widgets::modal::Modal,
@ -17,9 +9,20 @@ use crate::gui::{
widgets::package_row::PackageRow,
widgets::text,
};
use chrono;
use iced::widget::{Space, button, checkbox, column, container, pick_list, radio, row, scrollable};
use iced::{Alignment, Element, Length, Renderer, Task, alignment};
use log::{debug, error, info};
use std::path::PathBuf;
use uad_core::{
config::{BackupSettings, Config, DeviceSettings, GeneralSettings},
save::{backup_phone, list_available_backup_user, list_available_backups, restore_backup},
sync::{
AdbError, CorePackage, Phone, User, get_android_sdk, run_adb_shell_action,
supports_multi_user,
},
utils::{DisplayablePath, Error, NAME, export_packages, generate_backup_name, open_url},
};
#[derive(Debug, Clone)]
pub enum PopUpModal {
@ -101,10 +104,11 @@ impl Settings {
Task::none()
}
fn handle_expert_mode(&mut self, phone: &Phone, toggled: bool) -> Task<Message> {
fn handle_expert_mode(&mut self, _phone: &Phone, toggled: bool) -> Task<Message> {
self.general.expert_mode = toggled;
debug!("Config change: {self:?}");
Config::save_changes(self, &phone.adb_id);
let mut config = Config::load_configuration_file();
config.save_device_settings(self.device.clone(), self.general.clone());
Task::none()
}
@ -112,22 +116,25 @@ impl Settings {
if phone.android_sdk >= 23 {
self.device.disable_mode = toggled;
debug!("Config change: {self:?}");
Config::save_changes(self, &phone.adb_id);
let mut config = Config::load_configuration_file();
config.save_device_settings(self.device.clone(), self.general.clone());
}
Task::none()
}
fn handle_multi_user_mode(&mut self, phone: &Phone, toggled: bool) -> Task<Message> {
fn handle_multi_user_mode(&mut self, _phone: &Phone, toggled: bool) -> Task<Message> {
self.device.multi_user_mode = toggled;
debug!("Config change: {self:?}");
Config::save_changes(self, &phone.adb_id);
let mut config = Config::load_configuration_file();
config.save_device_settings(self.device.clone(), self.general.clone());
Task::none()
}
fn handle_apply_theme(&mut self, phone: &Phone, theme: Theme) -> Task<Message> {
fn handle_apply_theme(&mut self, _phone: &Phone, theme: Theme) -> Task<Message> {
self.general.theme = theme.to_string();
debug!("Config change: {self:?}");
Config::save_changes(self, &phone.adb_id);
let mut config = Config::load_configuration_file();
config.save_device_settings(self.device.clone(), self.general.clone());
Task::none()
}
@ -182,12 +189,16 @@ impl Settings {
phone: &Phone,
packages: &[Vec<PackageRow>],
) -> Task<Message> {
let core_packages: Vec<Vec<_>> = packages
.iter()
.map(|user_packages| user_packages.iter().map(CorePackage::from).collect())
.collect();
// Avoid capturing &self and &phone in the future; clone owned data first.
let user_list = phone.user_list.clone();
let device_id = self.device.device_id.clone();
let packages_owned = core_packages;
Task::perform(
backup_phone(
phone.user_list.clone(),
self.device.device_id.clone(),
packages.to_vec(),
),
async move { backup_phone(user_list, device_id, &packages_owned) },
Message::DeviceBackedUp,
)
}
@ -217,7 +228,11 @@ impl Settings {
packages: &[Vec<PackageRow>],
nb_running_async_adb_commands: &mut u32,
) -> Task<Message> {
match restore_backup(phone, packages, &self.device) {
let core_packages: Vec<Vec<_>> = packages
.iter()
.map(|user_packages| user_packages.iter().map(CorePackage::from).collect())
.collect();
match restore_backup(phone, &core_packages, &self.device) {
Ok(restore_result) => {
let mut commands = vec![];
*nb_running_async_adb_commands = 0;
@ -230,8 +245,13 @@ impl Settings {
};
for command in p.commands.clone() {
*nb_running_async_adb_commands += 1;
let p_info_clone = p_info.clone();
let phone_id = phone.adb_id.clone();
commands.push(Task::perform(
run_adb_action(phone.adb_id.clone(), command, p_info.clone()),
async move {
run_adb_shell_action(phone_id, command.as_str())
.map(|_| p_info_clone)
},
Message::RestoringDevice,
));
}
@ -274,7 +294,8 @@ impl Settings {
if let Ok(path) = result {
self.general.backup_folder = path;
Config::save_changes(self, &phone.adb_id);
let mut config = Config::load_configuration_file();
config.save_device_settings(self.device.clone(), self.general.clone());
self.load_device_settings(phone);
}
Task::none()
@ -293,8 +314,12 @@ impl Settings {
selected_user: Option<User>,
packages: &[Vec<PackageRow>],
) -> Task<Message> {
let core_packages: Vec<Vec<_>> = packages
.iter()
.map(|user_packages| user_packages.iter().map(CorePackage::from).collect())
.collect();
Task::perform(
export_packages(selected_user.unwrap_or_default(), packages.to_vec()),
async move { export_packages(selected_user.unwrap_or_default(), &core_packages) },
Message::PackagesExported,
)
}

View file

@ -25,6 +25,7 @@ impl<'a, Message, Theme, Renderer> Modal<'a, Message, Theme, Renderer> {
/// Sets the message that will be produces when the background
/// of the [`Modal`] is pressed
#[must_use]
pub fn on_blur(self, on_blur: Message) -> Self {
Self {
on_blur: Some(on_blur),

View file

@ -1,19 +1,22 @@
use crate::core::helpers::button_primary;
pub use crate::core::sync::Phone;
use crate::core::theme::Theme;
use crate::core::update::{SelfUpdateState, SelfUpdateStatus};
pub use crate::gui::views::about::Message as AboutMessage;
pub use crate::gui::views::list::{List as AppsView, LoadingState as ListLoadingState};
use crate::gui::{Message, style, widgets::text};
use crate::helpers::button_primary;
use crate::theme::Theme;
pub use crate::views::about::Message as AboutMessage;
pub use crate::views::list::{List as AppsView, LoadingState as ListLoadingState};
use crate::{gui::Message, style, widgets::text};
use iced::widget::{Space, button, container, pick_list, row, tooltip};
use iced::{Alignment, Element, Font, Length, Renderer, alignment, font};
pub use uad_core::sync::Phone;
use uad_core::update::{SelfUpdateState, SelfUpdateStatus};
/// resources/assets/icons.ttf, loaded in [`crate::gui::UadGui`]
pub const ICONS: Font = Font {
family: font::Family::Name("icomoon"),
..Font::DEFAULT
};
#[allow(clippy::too_many_lines)]
#[allow(
clippy::too_many_lines,
reason = "Navigation layout composes many widgets inline"
)]
pub fn nav_menu<'a>(
device_list: &'a [Phone],
selected_device: Option<Phone>,

View file

@ -1,9 +1,10 @@
use crate::core::sync::Phone;
use crate::core::theme::Theme;
use crate::core::uad_lists::{PackageState, Removal, UadList};
use crate::gui::style;
use crate::gui::views::settings::Settings;
use crate::gui::widgets::text;
use crate::style;
use crate::theme::Theme;
use crate::views::settings::Settings;
use crate::widgets::text;
use log::warn;
use uad_core::sync::{CorePackage, Phone};
use uad_core::uad_lists::{PackageState, Removal, UadList};
use iced::widget::{Space, button, checkbox, row};
use iced::{Alignment, Element, Length, Renderer, Task, alignment};
@ -11,10 +12,10 @@ use iced::{Alignment, Element, Length, Renderer, Task, alignment};
#[derive(Clone, Debug)]
pub struct PackageRow {
pub name: String,
pub state: PackageState,
pub description: String,
pub uad_list: UadList,
pub removal: Removal,
pub state: PackageState,
pub list: UadList,
pub selected: bool,
pub current: bool,
}
@ -27,21 +28,22 @@ pub enum Message {
}
impl PackageRow {
#[must_use]
pub fn new(
name: &str,
state: PackageState,
description: &str,
uad_list: UadList,
removal: Removal,
state: PackageState,
list: UadList,
selected: bool,
current: bool,
) -> Self {
Self {
name: name.to_string(),
state,
description: description.to_string(),
uad_list,
removal,
state,
list,
selected,
current,
}
@ -143,3 +145,56 @@ impl PackageRow {
.into()
}
}
// Conversions between PackageRow and CorePackage
impl From<CorePackage> for PackageRow {
fn from(core: CorePackage) -> Self {
Self {
name: core.name.clone(),
description: core.description,
removal: core.removal,
state: core.state,
list: core.list,
selected: false, // Default to not selected
current: false, // Default to not current
}
}
}
impl From<&CorePackage> for PackageRow {
fn from(core: &CorePackage) -> Self {
Self {
name: core.name.clone(),
description: core.description.clone(),
removal: core.removal,
state: core.state,
list: core.list,
selected: false,
current: false,
}
}
}
impl From<&PackageRow> for CorePackage {
fn from(row: &PackageRow) -> Self {
Self {
name: row.name.clone(),
description: row.description.clone(),
removal: row.removal,
state: row.state,
list: row.list,
}
}
}
impl From<PackageRow> for CorePackage {
fn from(row: PackageRow) -> Self {
Self {
name: row.name,
description: row.description,
removal: row.removal,
state: row.state,
list: row.list,
}
}
}

View file

@ -3,10 +3,11 @@
reason = "this is the replacement that enforces advanced shaping for disallowed [`iced::widget::Text`]"
)]
use crate::theme::Theme;
use iced::advanced::text::IntoFragment;
use iced::widget;
// Creates a new Text widget with advanced shaping.
pub fn text<'a>(text: impl IntoFragment<'a>) -> widget::Text<'a, crate::core::theme::Theme> {
pub fn text<'a>(text: impl IntoFragment<'a>) -> widget::Text<'a, Theme> {
widget::Text::new(text).shaping(iced::widget::text::Shaping::Advanced)
}

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1765270179,
"narHash": "sha256-g2a4MhRKu4ymR4xwo+I+auTknXt/+j37Lnf0Mvfl1rE=",
"lastModified": 1776949667,
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "677fbe97984e7af3175b6c121f3c39ee5c8d62c9",
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
"type": "github"
},
"original": {

View file

@ -27,10 +27,10 @@
with pkgs;
[
libglvnd
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
libX11
libXcursor
libXi
libXrandr
libxkbcommon
wayland
]

View file

@ -1,14 +0,0 @@
use crate::core::theme::Theme;
use crate::gui::style;
use iced::Element;
use iced::widget::button;
use iced::widget::button::Button;
/// Wrapper function for `iced::widget::button` with padding and style applied
pub fn button_primary<'a, Message>(
content: impl Into<Element<'a, Message, Theme>>,
) -> Button<'a, Message, Theme> {
button(content)
.padding([5, 10])
.style(style::Button::Primary)
}

View file

@ -1,9 +0,0 @@
pub mod adb;
pub mod config;
pub mod helpers;
pub mod save;
pub mod sync;
pub mod theme;
pub mod uad_lists;
pub mod update;
pub mod utils;