Simiyon Vinscent Samuel · Engineering

All posts / Android

  • Android
  • ADB
  • Privacy
  • PowerShell
  • Open source

Zero-root Android debloating: disable, don't uninstall, and always keep a way back

How unfuck-android debloats ColorOS, Funtouch, HyperOS and One UI phones over ADB without root: why pm disable-user beats uninstall, brand detection, a dry run and baseline-driven rollback, and per-category package lists vetted against two upstream databases.

My Oppo Reno8 5G shipped with 547 packages and 10.90 GB free on a 106 GB partition. A store I never opened was pushing notifications, a lock screen magazine was fetching sponsored wallpapers, and vendor diagnostic daemons were logging my usage. Factory reset puts all of it back, and rooting breaks banking apps. This post is about the third option, a zero-root debloat that runs from a laptop over ADB, and about the decisions that make it safe to run on a phone you actually use: why I disable instead of uninstall, how the scripts recognise a brand, how a dry run and a baseline make every change reversible, and how the package lists are organised and vetted.

The scripts change a flag, not a file. The numbered badges follow one package: it is read from a list (1), checked against the safety regex (2), sent as an ADB command (3), handled by the package manager (4) and recorded as a per-user state (5). The APK in the system partition is never written.
Every step has an inverse. The loop that matters is verify then restore: a change is only worth making if the way back is already on disk.

Understand the problem#

Commercial firmware ships vendor storefronts, lock screen magazines, a home-screen feed, diagnostic loggers and pre-exempted background services. Some of these are mild annoyances. A few move data off the phone. All of them hold RAM, wake the CPU and take storage.

Functional requirements#

  1. Disable or remove vendor apps, ad services and telemetry daemons without root.
  2. Pick the right package list for the phone automatically, across brands that share code and brands that do not.
  3. Never touch a package whose removal could break boot, calls, unlock or emergency services.
  4. Preview a run without changing anything.
  5. Return the phone to its exact starting state on request.
  6. Recover storage and reduce background work, and report what changed.

Non-functional requirements#

  1. No root, no unlocked bootloader. Banking apps and OTA updates must keep working.
  2. Reversible. The way back is saved before the first change.
  3. Conservative. A package with no evidence of safety stays untouched.
  4. Cross-platform. Windows PowerShell for the main phases, Python and shell for macOS and Linux.
  5. Observable. Every action prints what it did and what the phone said back.
  6. Version-tolerant. A list entry that does not exist on this build is skipped, not an error.
On the board so far
Phone <-- ADB --> scripts
scripts read lists//*.txt
change = per-user flag on a package
still needed: baseline, brand pick, safety rules, undo

The set-up#

Core entities#

There are only five things: a package (a reverse-domain name such as com.heytap.market), a list (a text file of packages with comments), a brand profile (which lists apply to a phone), a baseline (the saved state of every package) and a session (one run, with its restore script).

The state lives beside the file, not in it. That is why undoing a change is a flag flip or a reinstall from the copy that never left the phone.

The interface#

The commands, in the order a first-time user runs them:

Text
.\scripts\01-backup-baseline.ps1               # snapshot + sideloaded APKs
python tools/check.py lists/oppo/coloros-safe-debloat.txt
tools/disable.sh --dry-run lists/oppo/coloros-safe-debloat.txt
.\scripts\02-debloat.ps1 -Brand auto [-IncludeOptional]
.\scripts\03-optimize-battery.ps1                # doze, standby buckets
.\scripts\04-restore.ps1                         # replay the baseline
.\scripts\05-storage-report.ps1                  # HTML storage dashboard

The data model#

State is plain text: three baseline files and one list per brand and tier. There is no database.

The baseline files are three views of the same device. Restore compares the live phone to them, so it needs no memory of what the scripts did.
The same phone, before and after. An Oppo Reno8 5G on ColorOS 14 and Android 14. The baseline counts come from my experiment log; the before and after storage figures come from the project README.

Build it one requirement at a time#

1. Take a baseline before touching anything#

The first script dumps three views of the package manager: everything for user 0, everything enabled, and everything disabled. It also records free space on the data partition and pulls the APKs of anything installed outside the Play Store, because a sideloaded app that is later removed cannot be reinstalled from the system image.

Everything that undo needs is captured here. Nothing later in the pipeline writes to these files.
  1. The script checks that exactly one device is attached and authorised.
  2. It lists packages for user 0, then the enabled subset, then the disabled subset.
  3. It captures df -k /data so a later report can show recovered space.
  4. It lists third-party packages with their installer and pulls the base APK of those installed by hand or from a browser.

2. Detect the brand and choose lists#

Oppo, Realme and modern OnePlus share the ColorOS base, so their package prefixes overlap (com.heytap.*, com.coloros.*, com.oplus.*), while vivo and iQOO share BBK code. The script reads ro.product.brand and ro.product.manufacturer, lowercases both and matches on a substring. A table then maps each brand to a safe list and an optional list.

The brand decides the first list; three shared lists always apply. The optional list is added only when the user asks for it.
Text
if     ($rawBrand -match "oppo"    -or $rawManufacturer -match "oppo")    { $Brand = "oppo" }
elseif ($rawBrand -match "oneplus" -or $rawManufacturer -match "oneplus") { $Brand = "oneplus" }
elseif ($rawBrand -match "realme"  -or $rawManufacturer -match "realme")  { $Brand = "realme" }
elseif ($rawBrand -match "vivo"    -or $rawManufacturer -match "vivo")    { $Brand = "vivo" }
elseif ($rawBrand -match "iqoo")                                           { $Brand = "iqoo" }

A separate Python inspector reads OS-specific properties for display, such as ro.build.version.opporom for ColorOS, ro.vivo.os.version for Funtouch and OriginOS, ro.oxygen.version and ro.build.version.realmeui. The shell tools also fall back through pm disable-user, then pm disable, then pm hide, because older builds lack the first.

3. Decide what is safe#

A package gets into a safe list only if two independent community databases agree. The rule is deliberately lopsided toward caution: if either flags it, it moves to optional; if neither knows it, it is left alone. A hard-coded regex sits underneath all of that as the last line of defence.

Silence is not consent. The two upstream sources are the Universal Android Debloater Next Generation lists and the MuntashirAkon debloat list.

The regex rejects android, SystemUI, Settings, the phone and dialer stacks, the keyguard, the permission controller and package installer, the telephony and media providers, and the Qualcomm and MediaTek telephony services. The list validator, the PowerShell debloat script and the shell script each apply it, and the Python tools carry copies too. That duplication is a cost, and I come back to it below.

4. Disable, with a fallback#

Applying a list is a loop over names. Names not installed on this build are skipped. The disable function tries pm disable-user --user 0 and checks the reply for "new state"; if that fails it tries pm disable, then pm hide. Each attempt prints the phone's own reply, so a refusal is visible rather than swallowed.

A refusal is information, not an error. If the build protects a package, the script reports it as protected and moves on.

5. Undo#

The restore script does set arithmetic. It reads the baseline, lists what is installed now, and reinstalls anything that was installed before and is missing now with cmd package install-existing, falling back to a saved APK if that fails. It then re-enables anything that is currently disabled but was enabled and not disabled in the baseline. The packages the vendor had disabled before I started stay disabled.

Restore compares states, not histories. It does not matter which script made the change, or whether a session log survives.

Deep dive: why disable instead of uninstall#

pm uninstall -k --user 0 marks the package as not installed for the user and keeps its data. pm disable-user --user 0 keeps the package installed and registered, but disabled. From outside the two look identical: no icon, no process. The difference is how the rest of the system treats a missing name. Code that looks up a package it expects to exist can get an exception on the uninstalled one and a clean "disabled" answer on the disabled one.

Same visible result, different system behaviour. The package name is from my experiment log: removing it crashed a ColorOS service, and re-adding it as disabled stopped the crash with no background activity.
Uninstall everything for user 0

It reads as the thorough option, and on some builds it works. It is also the version where a hidden dependency shows up as a crash in a different app. It is only undone by reinstalling, which needs the original install path to be intact.

Uninstall, and fall back to disable when refused

This is what my main debloat script does. Modern ColorOS and Realme UI refuse pm uninstall on some packages with DELETE_FAILED_INTERNAL_ERROR, and vivo can raise a SecurityException, so a fallback is needed anyway.

Disable first; uninstall only for third-party apps

One command that works on both modern and older builds, one inverse (pm enable), and the package keeps existing in the registry. For frozen OEM apps that refuse even disable, pm suspend is the next step I used in the lab.

Deep dive: dry run and the safety gates#

The shell and PowerShell tools run the same gates in the same order. They validate each name against the package-name syntax, which catches BOM and CRLF contamination from Windows editors. They compare every name to the core regex and refuse the whole list if one matches, rather than skipping it. They write a timestamped backup folder with the installed and disabled lists and the device properties, and they generate a restore script, appending one line per package as it is processed. With --dry-run they stop right before the real command and print what would have happened.

The dry run shows the same skips a real run would make. Package names in the sample are real list entries; the counts and ordering are an example.

The lab version of this, which I used to test on my own phone before writing the project, goes further: a single-package script does a preflight (path, installed for user 0, current state, counts of declared services and receivers), disables, verifies by re-listing, and appends a transaction record with a five-point checklist for telephony, fingerprint, camera, push notifications and system UI. I ran one package at a time and checked, instead of applying a hundred at once.

Deep dive: per-category package lists#

The lists are organised on three axes. The directory is the brand. The file is a tier, safe or optional. Inside the file, comment headers split packages by what they do, so a reader can delete a whole block by intent. In the ColorOS safe list those blocks are brand app and theme stores, lock screen magazines and global search, brand cloud apps and accounts, and diagnostics. The optional list holds apps that are fine to remove only if the user has an alternative: weather, sound recorder, compass, phone clone.

Coverage follows how much each vendor adds. ColorOS has the longest list because the HeyTap, Coloros and Oplus namespaces each carry duplicates of the same service. The counts come from the list files in the repo.
  • Storefronts and theme stores. com.heytap.market, com.heytap.themestore: promotional pushes and in-app purchases.
  • Lock screen magazines. com.heytap.pictorial and the Glance service: sponsored wallpapers fetched over mobile data.
  • Diagnostics. com.oplus.logkit, com.oplus.onetrace: logging and event tracing.
  • Meta preloads. com.facebook.services and siblings: background daemons shipped in firmware.
  • Google ad and analytics modules. The AdServices and on-device personalisation packages, in the universal telemetry list.

Three shared lists apply to every brand: third-party bloat, Google telemetry and Facebook preloads. Two more exist, carrier bloat and a universal tracking list, but the main script does not load them; they are meant for the shell and Python tools. The same package often has two or three names across OS generations, which is why entries come in groups such as com.heytap.pictorial, com.coloros.pictorial and com.oplus.pictorial. The skip-if-absent rule is what makes that safe.

Deep dive: stopping the background drain#

Disabling packages is only one lever. The battery script handles three others. It removes pre-exempted packages from the Doze whitelist with dumpsys deviceidle whitelist. It places social and short-video apps in the restricted standby bucket, number 45, so they stop running in the background while still launching normally. And it disables the Google feed package behind the minus-one screen and restarts the launcher. For ads and trackers inside apps it recommends Private DNS with a filtering resolver, which works at the DNS layer without a VPN slot.

Restricting a bucket changes when an app may run, not whether it launches. The bucket number and commands are those in the project's battery script.

The final design#

Safety is a stack, not a feature. Lists, a regex, a dry run and a baseline each catch a different class of mistake.

One run, one rollback#

  1. Connect the phone, authorise ADB, and run the baseline script to capture package state and sideloaded APKs.
  2. Validate the chosen list, then dry-run it and read the skips.
  3. Apply the safe list; add the optional list only for apps you have replacements for.
  4. Apply the battery and standby changes and set Private DNS.
  5. Reboot, check calls, camera, fingerprint and notifications.
  6. If something is wrong, run the single-package restore, or the baseline restore for everything.

What I would change#

  • Make the main script disable first, to match the documentation and the lab finding about the accessory framework.
  • Add a dry-run switch to the main PowerShell script. Only the shell and PowerShell tools in tools/ have one.
  • Run the restore steps unconditionally. The generated restore line is a chain joined by ||: install-existing first, then pm enable only if it fails. A chain stops at the first command that exits zero, so pm enable may never run. My lab restore script runs unsuspend, enable and install-existing one after another instead.
  • Stop defaulting unknown brands to the Oppo profile; abort instead. Also match iQOO before vivo, because the vivo test also looks at the manufacturer string.
  • Keep one copy of the core regex. It is pasted into several scripts and tools and has to be edited in each.
  • Report where recovered storage came from. The benchmark of 24.46 GB mixes cache trims, removed third-party apps and package changes, and the README does not split them.

Test yourself#

Why does pm disable-user --user 0 need no root?

  1. It edits /system directly with ADB privileges.
  2. It only changes the per-user package state; the APK on the system partition is untouched.
  3. It unlocks the bootloader first.
  4. It runs as the SystemUI package.
B. It flips a user-level state, which the shell user is allowed to do.

What happened when the accessory framework package was fully uninstalled on ColorOS 14?

  1. The phone bootlooped.
  2. Another service threw NameNotFoundException; reinstalling it and disabling it for user 0 fixed the crash.
  3. Nothing; it was fine.
  4. The Play Store stopped working.
B. The lab log records the crash in a different service and the disable-user fix.

A package appears in only one of the two upstream lists and is rated safe there. What does the consensus rule do?

  1. Removes it.
  2. Removes it only because that one list rates it safe.
  3. Leaves it untouched always.
  4. Moves it to the core regex.
B. One list plus a safe rating is enough; a caution rating from either list moves it to optional, and absence from both means untouched.

Why does restore compare the live phone with the baseline instead of replaying a log?

  1. Logs are too large.
  2. State comparison works whichever script made the change and even if the session log is lost.
  3. ADB cannot write logs.
  4. The baseline is encrypted.
B. The baseline is a complete record of the starting state.

Why might the generated restore line fail to re-enable a disabled package?

  1. Disabled packages cannot be restored.
  2. The || chain stops at the first command that succeeds, so pm enable may never run.
  3. ADB is read only.
  4. The package is in the core regex.
B. Shell semantics: a succeeding command ends the chain; running each step unconditionally avoids the question.

Which part of the 24.46 GB recovery comes from disabling system packages?

  1. All of it.
  2. None directly: disabling frees no space in the read-only partition; the gain comes from caches and removed third-party apps.
  3. Half of it.
  4. Only the games.
B. The README's own storage model says APK code is only reclaimed by removal, and system APKs stay in place under user 0.