package main import ( "context" "fmt" "strings" "sync" "time" ) // ListPackages returns all installed packages filtered by type. // filterType: "user", "system", or "all" func (a *App) ListPackages(filterType string) ([]PackageInfo, error) { // Build base args - all discrete buildArgs := func(extra ...string) []string { args := []string{"pm", "list", "packages"} switch filterType { case "user": args = append(args, "-3") case "system": args = append(args, "-s") } return append(args, extra...) } parse := func(output string) []string { var out []string for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if pkg := strings.TrimPrefix(line, "package:"); pkg != line { out = append(out, strings.TrimSpace(pkg)) } } return out } var wg sync.WaitGroup var allPkgs, disabledPkgs []string var errAll error wg.Add(2) // Membership = the UNFILTERED list for this category. Every installed package // shows up here regardless of state. The old approach unioned `-e` (enabled) // and `-d` (disabled), but a ROM parks some apps in states (e.g. // DISABLED_UNTIL_USED) that neither filter reports, so system apps silently // went missing from App Inspector / APK Audit. Plain `pm list packages` is a // strict superset, so nothing is dropped. go func() { defer wg.Done() output, err := a.runAdbShell(buildArgs()...) if err != nil { errAll = err return } allPkgs = parse(output) }() // `-d` is used only to flag the disabled badge. Some ROMs restrict it; that's // non-fatal — it just means nothing gets marked disabled. go func() { defer wg.Done() output, err := a.runAdbShell(buildArgs("-d")...) if err != nil { return } disabledPkgs = parse(output) }() wg.Wait() if errAll != nil { return nil, fmt.Errorf("failed to list packages: %w", errAll) } disabled := make(map[string]bool, len(disabledPkgs)) for _, p := range disabledPkgs { disabled[p] = true } packages := make([]PackageInfo, 0, len(allPkgs)) seen := make(map[string]bool, len(allPkgs)) for _, p := range allPkgs { if seen[p] { continue } seen[p] = true packages = append(packages, PackageInfo{PackageName: p, IsEnabled: !disabled[p]}) } return packages, nil } // InstallPackage installs an APK from a local file path. // filePath is a discrete arg - safe. func (a *App) InstallPackage(filePath string) (string, error) { ctx, cancel := a.beginCancellableOp(15 * time.Minute) defer cancel() // adb install -r - all discrete args output, err := a.runCommandContext(ctx, "adb", "install", "-r", filePath) if err != nil { if strings.Contains(err.Error(), "cancelled") { return "", fmt.Errorf("installation cancelled") } return "", fmt.Errorf("install failed: %w", err) } return output, nil } // UninstallPackage uninstalls a package for user 0. // packageName is a discrete arg - safe. func (a *App) UninstallPackage(packageName string) (string, error) { if err := a.requireDangerUnlocked(); err != nil { return "", err } if err := validatePackageName(packageName); err != nil { return "", err } // `pm uninstall --user 0 ` - uninstall for the primary user only. // // Pre-installed / system apps (what a debloater targets) cannot be deleted // from the read-only system partition without root, but they CAN be removed // for the current user. This is exactly how Canta/Shizuku and UAD-ng debloat // them. Bare `pm uninstall ` (no --user) attempts a full removal and the // system rejects it with a "not allowed for the user" / DELETE_FAILED_* error. // See UAD-ng src/core/sync.rs (request_builder + user_flag). output, err := a.runAdbShell("pm", "uninstall", "--user", "0", packageName) if err != nil { // Already gone for user 0 is effectively success (idempotent debloat). if isAlreadyUninstalled(err.Error()) { return fmt.Sprintf("%s already uninstalled for user 0", packageName), nil } // Protected system app: the pm CLI can't set DELETE_SYSTEM_APP. Fall // back to the privileged app_process helper (Canta/Shizuku technique). if isProtectedSystemApp(err.Error()) { return a.privilegedUninstallFallback(packageName, err.Error()) } return "", fmt.Errorf("uninstall failed for %s: %s", packageName, friendlyPmError(err.Error())) } // pm can exit 0 while printing "Failure [...]" to stdout on some Android builds. if strings.Contains(output, "Failure") { if isAlreadyUninstalled(output) { return fmt.Sprintf("%s already uninstalled for user 0", packageName), nil } if isProtectedSystemApp(output) { return a.privilegedUninstallFallback(packageName, output) } return "", fmt.Errorf("uninstall failed for %s: %s", packageName, friendlyPmError(output)) } // pm reported success - but for an updatable system app `pm uninstall --user 0` // only removes the *updates* (reverts to factory) and leaves the app installed. // Don't trust the success blindly: if it's still present for user 0, escalate // to the privileged DELETE_SYSTEM_APP helper to actually remove it. if a.isInstalledForUser(packageName, 0) { if pout, perr := a.privilegedUninstall(packageName, 0); perr == nil { return pout, nil } if a.isInstalledForUser(packageName, 0) { return "", fmt.Errorf("uninstall for %s reported success but it is still installed for user 0 (likely a required system app - try disabling it)", packageName) } } return output, nil } // privilegedUninstallFallback runs the on-device privileged helper after a // `pm uninstall` system-app rejection, surfacing a combined error on failure. func (a *App) privilegedUninstallFallback(packageName, pmFailure string) (string, error) { out, err := a.privilegedUninstall(packageName, 0) if err != nil { return "", fmt.Errorf("uninstall failed for %s: %s (privileged fallback: %v)", packageName, friendlyPmError(pmFailure), err) } return out, nil } // DisablePackage disables a package for user 0. // packageName is a discrete arg - safe. func (a *App) DisablePackage(packageName string) (string, error) { if err := a.requireDangerUnlocked(); err != nil { return "", err } if err := validatePackageName(packageName); err != nil { return "", err } // pm disable-user --user 0 - all discrete args output, err := a.runAdbShell("pm", "disable-user", "--user", "0", packageName) if err != nil { return "", fmt.Errorf("disable failed for %s: %w", packageName, err) } // Accept any "new state:" response as success if strings.Contains(output, "new state:") { return output, nil } return "", fmt.Errorf("disable failed for %s: %s", packageName, output) } // EnablePackage enables a previously disabled package. // packageName is a discrete arg - safe. func (a *App) EnablePackage(packageName string) (string, error) { if err := validatePackageName(packageName); err != nil { return "", err } // pm enable --user 0 - all discrete args output, err := a.runAdbShell("pm", "enable", "--user", "0", packageName) if err != nil { return "", fmt.Errorf("enable failed for %s: %w", packageName, err) } // Accept "new state: enabled" or "new state: enabled-user" (Android version variants) if strings.Contains(output, "new state: enabled") { return output, nil } return "", fmt.Errorf("enable failed for %s: %s", packageName, output) } // ClearData clears app data for a package. func (a *App) ClearData(packageName string) (string, error) { if err := validatePackageName(packageName); err != nil { return "", err } output, err := a.runAdbShell("pm", "clear", packageName) if err != nil { return "", fmt.Errorf("clear data failed for %s: %w", packageName, err) } if strings.Contains(output, "Failed") { return "", fmt.Errorf("clear data failed for %s: %s", packageName, output) } return "Data cleared successfully", nil } // PullApk pulls an installed APK from the device to a user-chosen local path. func (a *App) PullApk(packageName string) (string, error) { if err := validatePackageName(packageName); err != nil { return "", err } // Get remote APK path - pm path - discrete args pathOutput, err := a.runAdbShell("pm", "path", packageName) if err != nil { return "", fmt.Errorf("cannot find APK for %s: %w", packageName, err) } remotePath := strings.TrimPrefix(strings.TrimSpace(pathOutput), "package:") remotePath = strings.TrimSpace(remotePath) if remotePath == "" { return "", fmt.Errorf("could not parse APK path from: %s", pathOutput) } localPath, err := a.SelectSaveFile(packageName + ".apk") if err != nil { return "", fmt.Errorf("save dialog failed: %w", err) } if localPath == "" { return "APK pull cancelled.", nil } ctx, cancel := a.beginCancellableOp(10 * time.Minute) defer cancel() // adb pull - all discrete args _, err = a.runCommandContext(ctx, "adb", "pull", remotePath, localPath) if err != nil { if strings.Contains(err.Error(), "cancelled") { return "", fmt.Errorf("pull cancelled") } return "", fmt.Errorf("pull failed: %w", err) } return fmt.Sprintf("APK saved to %s", localPath), nil } // UninstallMultiplePackages uninstalls a list of packages. func (a *App) UninstallMultiplePackages(packageNames []string) (string, error) { return a.batchPackageOp("uninstall", packageNames, a.UninstallPackage) } // DisableMultiplePackages disables a list of packages. func (a *App) DisableMultiplePackages(packageNames []string) (string, error) { return a.batchPackageOp("disable", packageNames, a.DisablePackage) } // UninstallAndDisablePackage force-stops and disables a package for user 0, // then uninstalls it. Neutralising it first guarantees the app is stopped and // disabled even if the uninstall can't fully remove it (the disabled state // remains as a safety net). func (a *App) UninstallAndDisablePackage(packageName string) (string, error) { if err := a.requireDangerUnlocked(); err != nil { return "", err } if err := validatePackageName(packageName); err != nil { return "", err } var steps []string // 1. Force-stop (best effort). if _, err := a.runAdbShell("am", "force-stop", packageName); err == nil { steps = append(steps, "force-stopped") } // 2. Disable for user 0 (best effort - keep going even if it fails). if _, err := a.DisablePackage(packageName); err == nil { steps = append(steps, "disabled") } else { steps = append(steps, fmt.Sprintf("disable failed (%v)", err)) } // 3. Uninstall for user 0 (with privileged fallback for protected apps). if _, err := a.UninstallPackage(packageName); err != nil { // Uninstall failed, but the app is at least force-stopped/disabled. return "", fmt.Errorf("%s: %s; %v", packageName, strings.Join(steps, ", "), err) } steps = append(steps, "uninstalled") return fmt.Sprintf("%s: %s", packageName, strings.Join(steps, ", ")), nil } // UninstallAndDisableMultiplePackages applies the combined disable+uninstall op // to a list of packages. func (a *App) UninstallAndDisableMultiplePackages(packageNames []string) (string, error) { return a.batchPackageOp("uninstall+disable", packageNames, a.UninstallAndDisablePackage) } // EnableMultiplePackages enables a list of packages. func (a *App) EnableMultiplePackages(packageNames []string) (string, error) { return a.batchPackageOp("enable", packageNames, a.EnablePackage) } // RestorePackage brings a package back for user 0. It reinstalls it if it was // uninstalled-for-user (cmd package install-existing) and re-enables it if it // was disabled (pm enable). Both steps are idempotent, so this works whether // the package was disabled, uninstalled, or both. func (a *App) RestorePackage(packageName string) (string, error) { if err := validatePackageName(packageName); err != nil { return "", err } var steps []string // 1. Reinstall for user 0 (no-op if already installed; required if it was // uninstalled for the user). Must run before enable. if out, err := a.runAdbShell("cmd", "package", "install-existing", "--user", "0", packageName); err == nil { if strings.Contains(out, "installed for user") { steps = append(steps, "reinstalled") } } // 2. Re-enable for user 0 (no-op if already enabled; required if it was disabled). if out, err := a.runAdbShell("pm", "enable", "--user", "0", packageName); err == nil { if strings.Contains(out, "new state: enabled") { steps = append(steps, "enabled") } } if len(steps) == 0 { if a.isInstalledForUser(packageName, 0) { return fmt.Sprintf("%s already active", packageName), nil } return "", fmt.Errorf("could not restore %s (it may not be present on the system)", packageName) } return fmt.Sprintf("%s: %s", packageName, strings.Join(steps, ", ")), nil } // RestoreMultiplePackages restores (reinstall + re-enable) a list of packages. func (a *App) RestoreMultiplePackages(packageNames []string) (string, error) { return a.batchPackageOp("restore", packageNames, a.RestorePackage) } // batchPackageOp runs a package operation on multiple packages and summarises results. func (a *App) batchPackageOp(opName string, packageNames []string, op func(string) (string, error)) (string, error) { if len(packageNames) == 0 { return "", fmt.Errorf("no packages selected") } var successCount, failCount int var errDetails strings.Builder for _, pkg := range packageNames { if _, err := op(pkg); err != nil { failCount++ errDetails.WriteString(fmt.Sprintf("• %s: %v\n", pkg, err)) } else { successCount++ } } summary := fmt.Sprintf("Successfully %sd %d package(s).", opName, successCount) if failCount > 0 { summary += fmt.Sprintf("\nFailed: %d\n%s", failCount, errDetails.String()) } return summary, nil } // ForceStopPackage force stops a running app. func (a *App) ForceStopPackage(packageName string) (string, error) { if err := validatePackageName(packageName); err != nil { return "", err } _, err := a.runAdbShell("am", "force-stop", packageName) if err != nil { return "", fmt.Errorf("force stop failed: %w", err) } return fmt.Sprintf("Force stopped %s", packageName), nil } // GetPackageInfo returns detailed info about a package. func (a *App) GetPackageInfo(packageName string) (string, error) { if err := validatePackageName(packageName); err != nil { return "", err } // dumpsys package - discrete args output, err := a.runAdbShellTimeout(10*time.Second, "dumpsys", "package", packageName) if err != nil { return "", fmt.Errorf("failed to get package info: %w", err) } return output, nil } // SideloadPackage sideloads a package via adb sideload (for OTA updates in recovery). func (a *App) SideloadPackage(filePath string) (string, error) { if err := a.requireDangerUnlocked(); err != nil { return "", err } ctx, cancel := a.beginCancellableOp(0) // No timeout - user cancellable defer cancel() output, err := a.runCommandContext(ctx, "adb", "sideload", filePath) if err != nil { return "", fmt.Errorf("sideload failed: %w", err) } return output, nil } // isAlreadyUninstalled reports whether a pm failure just means the package is // no longer present for user 0 (so a debloat uninstall is effectively done). func isAlreadyUninstalled(text string) bool { return strings.Contains(text, "not installed for") || strings.Contains(text, "NOT_INSTALLED_FOR_USER") } // friendlyPmError maps common pm / OEM uninstall failure codes to readable // guidance. Ported from UAD-ng's make_friendly_error_message (src/core/sync.rs). // Note: on a non-zero exit the executor returns stderr only, while pm writes // "Failure [REASON]" to stdout - so the reason text isn't always available; in // that case we surface whatever we have. func friendlyPmError(text string) string { switch { case isProtectedSystemApp(text): // Reached only when the privileged app_process helper also failed - // likely an active device-admin/role or a non-removable required app. return "protected system app - even the privileged helper could not remove it (it may be an active device-admin or a required app). Try disabling it instead." case strings.Contains(text, "DELETE_FAILED_USER_RESTRICTED"): return "restricted by the device manufacturer (Samsung Knox or similar). Try disabling the package instead." case strings.Contains(text, "DELETE_FAILED_DEVICE_POLICY_MANAGER"): return "managed by device policy (MDM/EMM) - contact your IT administrator if this is a work device." case strings.Contains(text, "Permission denied") || strings.Contains(text, "INSTALL_FAILED_PERMISSION_MODEL_DOWNGRADE"): return "permission denied - the package is protected by the system and may require root." case strings.Contains(text, "Shell cannot change component state for null"): return "empty package name - refresh the package list and try again." case text == "" || strings.Contains(text, "exit status"): return "device rejected the uninstall (the app may be protected, a device-admin, or required by the system)." default: return strings.TrimSpace(text) } } // validatePackageName checks that a package name looks like a valid Android package. // Android packages are dot-separated identifiers: com.example.app // This prevents passing arbitrary strings as package names. func validatePackageName(name string) error { name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("package name cannot be empty") } // Basic sanity: must not contain shell metacharacters // Since we pass as discrete args this is belt-and-suspenders, // but good to validate inputs regardless for _, ch := range name { if ch == ';' || ch == '&' || ch == '|' || ch == '`' || ch == '$' || ch == '\n' || ch == '\r' { return fmt.Errorf("invalid character in package name: %q", ch) } } return nil } // RunAdbHostCommand runs a raw adb command from the terminal view. // args is split on spaces and each element passed as a discrete arg. // This is intentionally permissive since it's the shell terminal feature. func (a *App) RunAdbHostCommand(rawArgs string) (string, error) { if rawArgs == "" { return "", fmt.Errorf("command cannot be empty") } args := strings.Fields(rawArgs) if len(args) == 0 { return "", fmt.Errorf("no arguments provided") } // Restrict to known safe adb subcommands in the terminal // The shell view still allows full adb usage, this just prevents // passing arbitrary binaries ctx, cancel := context.WithTimeout(context.Background(), DefaultCommandTimeout) defer cancel() return a.runCommandContext(ctx, "adb", args...) } // RunShellCommand runs an adb shell command from the terminal view. // The command string is split on spaces - NO shell interpretation. // This means pipes/redirects won't work, but it's much safer. func (a *App) RunShellCommand(command string) (string, error) { if command == "" { return "", fmt.Errorf("command cannot be empty") } // Split into discrete args - no shell, no injection args := strings.Fields(command) return a.runAdbShell(args...) }