Merge PR #1813: tools: restore.sh turns back on what check.py --fix or a second disable.sh run disabled

This commit is contained in:
Gathin23 committed 2026-10-26 09:54:52 +05:30
commit eb29abe950
7 files changed
+293 -47

No files matched your search

+11 -5
View File
@@ -724,7 +724,9 @@ Before `tools/disable.sh` changes anything it writes down what the TV looked lik
in the list whether it was enabled, already disabled or not installed, which component owned Home,
and the three animation scales. That record lives in `~/.cleanyourtv/`, one file per TV and list, and
a second run of the same list keeps the first record instead of saving the cleaned-up state over it.
If you added packages to the list since, that run adds them to the record before disabling them.
If you added packages to the list since, that run adds them to the record before disabling them, and
a listed package that was not installed then but is installed and enabled now is recorded as enabled.
`check.py --fix` does the same before it disables anything.
Keep the list you applied. The restore script replays the record, so a package you had disabled
yourself stays disabled and Home goes back to the component that owned it:
@@ -756,7 +758,9 @@ TV_HOST=192.168.1.50 tools/venv/bin/python -I tools/check.py examples/<your tv>/
It exits with `0` when the TV still matches the list, `1` when something drifted, and `2` when it
could not check at all: the TV did not answer, nobody accepted the "Allow USB debugging?" prompt in
time, the list path is not there, or the list disables Android's core packages. Only 1 is drift, so
time, the list path is not there, the list disables Android's core packages, or, under `--fix`, the
recovery record for this TV and list cannot be read or updated (nothing is changed on the TV then).
Only 1 is drift, so
a job that mails on 1 alone stays quiet while the check never ran; mail on anything but 0, or on 2 as
well, and a weekly job on a home server or a Pi is enough to hear about it:
@@ -770,9 +774,11 @@ your shell.
`--fix` reapplies the list to what drifted: it switches Home to the launcher the list does not
disable (pass a package as the second argument if you run a fork), then disables the listed packages
that came back, and reads the TV back to confirm both. It writes no recovery record, because the
record already holds the state from before the first run: `tools/restore.sh` still returns the TV to
stock afterwards. Home is fixed first; if no launcher is left to take it, `--fix` changes nothing
that came back, and reads the TV back to confirm both. It never writes a recovery record of its own,
because only the first run's record holds the state from before cleanup. When there is one, `--fix`
first adds to it what a second `tools/disable.sh` run would (names the list gained, and a package that
was not installed then and is installed and enabled now), so `tools/restore.sh` still returns the TV
to stock afterwards. Home is fixed first; if no launcher is left to take it, `--fix` changes nothing
and says so.
It runs the same safety checks as `tools/disable.sh` before it disables anything, since an update is
+17 -10
View File
@@ -179,16 +179,23 @@ Things that bit us or contributors, so they don't bite you. Commands use `tools/
"not installed any more". Bring it back with
`tools/tv.py 'cmd package install-existing --user 0 <package>'` (AOSP's `cmd package` has it from
Android 8.0; the Android 6 set in [#1185](https://github.com/Gathin23/cleanyourtv/issues/1185) had no
such command; no TV here has run it yet). It comes back enabled, and without its data unless the
uninstall used `-k`. To keep it, comment its line out of your list first: otherwise `check.py` reports
it as back on, and `--fix` or the next `disable.sh` run disables it again. If you don't want it, let
the list disable it afterwards, which the TV's own settings can undo. The record still calls it
absent, so `restore.sh` keeps leaving it alone: `pm enable --user 0 <package>` is its undo. Two cases
this does not cover. An app with no copy on the system partition that only user 0 had is deleted with
its APK: the command answers `Package <package> doesn't exist`, and only reinstalling the APK brings
it back (the Toshiba's `com.hisense.business.ad`). And a `User 0:` line with
`installed=true hidden=true` was hidden, not uninstalled. install-existing changes nothing there even
though it prints `Package <package> installed for user: 0`, and the shell cannot unhide it:
such command; no TV here has run it yet). It comes back without its data unless the uninstall used
`-k`, and disabled if a list that names it has gone through `disable.sh` since it was removed: that
run sent it `pm disable-user --user 0`, which AOSP applies to a package removed for user 0 too (the
[Shield list](../examples/nvidia-shield-tv-pro-2019/debloat.txt) notes `disable.sh` reporting
"disabled-user" for such packages), and install-existing marks it installed without changing that
state. `pm list packages -d --user 0 <package>` names it if it came back disabled. To keep it, comment
its line out of your list, then run `tools/tv.py 'pm enable --user 0 <package>'`: with the line still
in, `check.py` reports it as back on, and `--fix` or the next `disable.sh` run disables it again. If
you don't want it, keep the line: the list disables it, or keeps it disabled, which the TV's own
settings can undo. `--fix` or a `disable.sh` run that finds it enabled first changes it from absent to
enabled in the record, so `restore.sh` turns it back on too. Until then, or in a record an older
version updated, it stays absent, and `restore.sh` leaves it alone: `pm enable --user 0 <package>` is
its undo. Two cases this does not cover. An app with no copy on the system partition that only user 0
had is deleted with its APK: the command answers `Package <package> doesn't exist`, and only
reinstalling the APK brings it back (the Toshiba's `com.hisense.business.ad`). And a `User 0:` line
with `installed=true hidden=true` was hidden, not uninstalled. install-existing changes nothing there
even though it prints `Package <package> installed for user: 0`, and the shell cannot unhide it:
`pm unhide` needs `MANAGE_USERS`, which the shell does not hold from Android 7.0. Unhide it from the
device-owner app that hid it, or leave it, as the
[Chromecast with Google TV 4K list](../examples/google-chromecast-gtv-4k/debloat.txt) does with
+182
View File
@@ -0,0 +1,182 @@
"""check.py --fix and a second disable.sh run keep Undo able to turn back on what they disable.
restore.sh puts back only what the recovery record holds. check.py --fix never touched the record, so
a name the list gained after the last disable.sh run was disabled by --fix and then left off by
restore.sh for good ("not in the record, leaving alone"). A package the record calls absent, because
the TV did not have it when disable.sh first ran, was left off the same way once an update or the Play
Store installed it and --fix or disable.sh disabled it: save() only ever added missing names. Both now
go through recovery.extend_record(), which adds the names the record does not hold and records an
absent package that is installed and enabled now as enabled. check.py only extends a record that
exists: a record of the TV after cleanup would make Undo leave the whole list off.
The TV is the drift check's fake tools/tv.py (tests/test_drift_check.py), which applies what --fix and
a batch send, and the real disable.sh, restore.sh, check.py and recovery.py run against it.
Run from the repository root: python3 -m unittest discover -s tests -v
"""
import json
import os
from pathlib import Path
import shutil
import sys
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_drift_check as drift # noqa: E402 (imported as a module so its tests do not run twice)
import test_recovery_record as record_test # noqa: E402 (the same)
ROOT = drift.ROOT
STOCK = drift.STOCK
WEATHER = "com.example.weather"
class CheckFixRecordTest(unittest.TestCase):
# The drift check's harness: a temporary repo with the fake tv.py, HOME, the list and the TV state.
write_list = drift.DriftCheckTest.write_list
tv = drift.DriftCheckTest.tv
run_check = drift.DriftCheckTest.run_check
sent = drift.DriftCheckTest.sent
# The recovery record test's runner for disable.sh and restore.sh, on the same repo and state.
run_script = record_test.RecoveryRecordTest.run_script
def setUp(self):
# The record's old name carries the port, so a TV_PORT left in the environment (by the user,
# or by an earlier test in the same run) must not reach the scripts.
environ = mock.patch.dict(os.environ)
environ.start()
self.addCleanup(environ.stop)
os.environ.pop("TV_PORT", None)
drift.DriftCheckTest.setUp(self)
for script in ("disable.sh", "restore.sh"):
shutil.copy2(ROOT / "tools" / script, self.repo / "tools" / script)
# The scripts call tools/venv/bin/python: run the real interpreter, which imports the fake
# tv.py for the transport and the real recovery.py for the logic.
client = self.repo / "tools" / "venv" / "bin" / "python"
client.parent.mkdir(parents=True)
client.write_text(f'#!/bin/sh\nexec "{sys.executable}" "$@"\n')
client.chmod(0o755)
self.write_list(["com.example.music", "com.example.store"])
def state(self):
return json.loads(self.state_path.read_text())
def set_state(self, **changes):
state = self.state()
state.update(changes)
self.state_path.write_text(json.dumps(state))
def record_file(self):
files = list((self.home / ".cleanyourtv").glob("recovery-*.json"))
self.assertEqual(len(files), 1, files)
return files[0]
def record(self):
return json.loads(self.record_file().read_text())["packages"]
def apply_list(self):
result = self.run_script("disable.sh", str(self.list_path))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return result
def undo(self):
self.set_state(commands=[])
result = self.run_script("restore.sh", str(self.list_path))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
return self.sent(), result.stderr
def test_a_name_the_list_gained_is_recorded_before_fix_disables_it(self):
self.tv(installed=drift.BASE + [WEATHER], disabled=[])
self.apply_list()
# A git pull adds a line; the next --fix disables it.
self.write_list(["com.example.music", "com.example.store", WEATHER])
result = self.run_check("--fix", str(self.list_path))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(f"pm disable-user --user 0 {WEATHER}", self.sent())
self.assertIn("added 1 names the list gained since then", result.stdout)
self.assertEqual(self.record()[WEATHER], "enabled")
sent, err = self.undo()
self.assertIn(f"pm enable {WEATHER}", sent)
self.assertNotIn("not in the record", err)
def test_a_package_installed_after_the_record_is_recorded_before_fix_disables_it(self):
self.write_list(["com.example.music", "com.example.store", "com.example.later"])
self.tv(installed=drift.BASE, disabled=[STOCK])
self.apply_list()
self.assertEqual(self.record()["com.example.later"], "absent")
# A TV refuses pm disable-user for a package it has no copy of, which this fake does not; then
# an update installs it, enabled.
state = self.state()
self.set_state(installed=state["installed"] + ["com.example.later"],
disabled=[name for name in state["disabled"] if name != "com.example.later"])
result = self.run_check("--fix", str(self.list_path))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("pm disable-user --user 0 com.example.later", self.sent())
self.assertEqual(self.record()["com.example.later"], "enabled")
sent, err = self.undo()
self.assertIn("pm enable com.example.later", sent)
self.assertNotIn("leaving com.example.later alone", err)
def test_disable_run_again_records_a_package_installed_since(self):
self.write_list(["com.example.music", "com.example.store", "com.example.later"])
self.tv(installed=drift.BASE, disabled=[STOCK])
self.apply_list()
state = self.state()
self.set_state(installed=state["installed"] + ["com.example.later"],
disabled=[name for name in state["disabled"] if name != "com.example.later"])
self.apply_list()
self.assertEqual(self.record()["com.example.later"], "enabled")
sent, _ = self.undo()
self.assertIn("pm enable com.example.later", sent)
def test_entries_from_before_cleanup_are_not_overwritten(self):
self.write_list(["com.example.music", "com.example.store", "com.example.later"])
self.tv(installed=drift.BASE, disabled=["com.example.store", STOCK])
self.apply_list()
first = self.record()
self.assertEqual(first["com.example.store"], "disabled")
# Installed since, but disabled: nobody can say by whom, so it stays absent, which restore
# leaves alone exactly as it would leave a disabled one.
state = self.state()
self.set_state(installed=state["installed"] + ["com.example.later"],
disabled=[name for name in state["disabled"] if name != "com.example.music"])
result = self.run_check("--fix", str(self.list_path))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(self.record(), first)
def test_a_report_without_fix_leaves_the_record_alone(self):
self.tv(installed=drift.BASE + [WEATHER], disabled=[])
self.apply_list()
before = self.record_file().read_bytes()
self.write_list(["com.example.music", "com.example.store", WEATHER])
result = self.run_check(str(self.list_path))
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertEqual(self.record_file().read_bytes(), before)
def test_a_record_fix_cannot_use_stops_it_before_the_tv(self):
for case in ("damaged", "two names"):
with self.subTest(case=case):
self.setUp()
self.tv(disabled=[])
self.apply_list()
path = self.record_file()
if case == "damaged":
path.write_text("{not json")
else:
# The name older versions gave a record on the network, next to the one it gets now.
shutil.copy2(path, path.with_name(path.name.replace("192.168.1.50-",
"192.168.1.50_5555-")))
before = path.read_bytes()
# An update turned one listed package back on.
self.set_state(commands=[], disabled=["com.example.store"])
result = self.run_check("--fix", str(self.list_path))
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertIn(str(path), result.stderr)
self.assertEqual(self.sent(), [], "nothing reached the TV")
self.assertEqual(path.read_bytes(), before)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,8 +1,8 @@
"""tools/check.py against a simulated TV (#359).
The property worth testing is the one no TV shows on demand: what the drift report says after a
firmware update, and that --fix puts back only what drifted, without touching the recovery record
undo depends on. tools/tv.py is replaced by a fake that answers the queries from a JSON file, so the
firmware update, and that --fix puts back only what drifted, without writing a recovery record of
its own. tools/tv.py is replaced by a fake that answers the queries from a JSON file, so the
real check.py logic runs and only the ADB transport is simulated.
Run from the repository root: python3 -m unittest discover -s tests -v
+5 -6
View File
@@ -9,8 +9,8 @@ replaced by a stand-in that keeps what `tools/tv.py --batch` was given.
examples/anker-nebula-mars-3-air: the header and README said `tools/check.py --fix` re-disables
Assistant after a boot, but the list keeps com.google.android.katniss commented out and check.py reads
only active lines, so --fix never touched it. They now say to uncomment it and its LED service, then
run Apply again: check.py --fix never writes the recovery record, and restore.sh leaves alone every
listed name the record does not hold, so only disable.sh can make Undo bring them back. The owner's
run Apply again: disable.sh adds them to the recovery record first, and restore.sh leaves alone every
listed name the record does not hold, so that is what makes Undo bring them back. The owner's
choice stays: the shipped list still leaves Assistant on. That half reuses the drift check's harness and
fake tools/tv.py from tests/test_drift_check.py, and runs the real disable.sh and restore.sh against
it with run_script from tests/test_recovery_record.py.
@@ -167,12 +167,11 @@ class AnkerAssistantTest(unittest.TestCase):
self.assertEqual(self.sent(), [f"pm disable-user --user 0 {name}" for name in ASSISTANT])
def test_undo_brings_assistant_back_after_the_header_steps(self):
# check.py --fix applies the list without touching the recovery record, and restore.sh leaves
# alone every listed name the record does not hold, so a package only check.py disabled stays
# off after Undo. disable.sh adds the names a list gained to the record before disabling them.
# restore.sh leaves alone every listed name the recovery record does not hold, and disable.sh
# adds the names a list gained to the record before disabling them.
names = self.told_to_uncomment()
told = self.paragraph.split("uncomment", 1)[1].split("check.py", 1)[0]
self.assertIn("run Apply again", told, "only disable.sh records the uncommented lines for Undo")
self.assertIn("run Apply again", told, "Apply records the uncommented lines for Undo")
lines = header(ANKER / "debloat.txt")
apply = next(line for line in lines if line.startswith("# Apply:"))
undo = next(line for line in lines if line.startswith("# Undo:"))
+27 -8
View File
@@ -8,11 +8,13 @@
A firmware update re-enables listed packages, hands Home back to the stock launcher and adds new
preloads, and nothing in this repo notices: nobody runs disable.sh again, and restore.sh would undo
the cleanup instead of redoing it. This command compares the TV with the list and exits 1 when the
TV drifted, so it fits a cron job; --fix applies the list again without touching the recovery
record (the packages were already recorded before the first run, so undo still means undo).
TV drifted, so it fits a cron job; --fix applies the list again. It never writes a recovery record of
its own (a record of the TV after cleanup would make undo leave the whole list disabled), but when
this TV and list have one, it first adds what disable.sh would: the names the list gained since then,
and a package the record calls absent that is installed and enabled now. Undo then still means undo.
It reads the list, not the record: the record holds what the TV looked like *before* cleanup, which
is what restore needs, while the target here is the list itself.
It compares the TV with the list, not the record: the record holds what the TV looked like *before*
cleanup, which is what restore needs, while the target here is the list itself.
--fix runs the same safety checks as disable.sh before it disables anything (the Wi-Fi network
setupwraith owns, the packages under the HDMI/AV inputs), because a firmware update is exactly the
@@ -24,7 +26,8 @@ Exit codes: 0 in sync (or fixed), 1 drift, an incomplete fix, or packages a safe
--fix may be partial, and the next check says what is left), or the command line asked for something this
script does not do, which includes naming a launcher the TV does not have. That last one matters:
README.md tells an owner to run this from a weekly job and hear about it on exit 1, so a mistyped flag
must not come back as drift, nor a mistyped launcher as "nothing drifted".
must not come back as drift, nor a mistyped launcher as "nothing drifted". A recovery record --fix
cannot read or update is 2 as well, with nothing changed on the TV.
"""
import os
import re
@@ -347,7 +350,8 @@ def main():
return 2
fixing = False
try:
installed, _, home, back_on, gone, home_drifted, expected = inspect(dev, listed, preferred)
installed, disabled, home, back_on, gone, home_drifted, expected = inspect(dev, listed,
preferred)
if preferred and preferred not in installed:
# The other half of the launcher check before the list was read: only the TV can say
# whether the package is there.
@@ -357,12 +361,27 @@ def main():
return 2
code = report(list_path, listed, home, back_on, gone, home_drifted, expected)
if code and wanted:
# restore.sh puts back only what the recovery record holds, so before anything is disabled,
# a record this TV and list already have gets the entries a second disable.sh run would add
# (recovery.extend_record). Without a record nothing is written: one taken now would hold
# the TV after cleanup. A record that cannot be used stops the run before anything reaches
# the TV, as it stops disable.sh.
path = recovery.find_record(list_path, os.environ.get("TV_HOST", ""))
if path.exists():
try:
recovery.extend_record(path, recovery.load_record(path), listed, installed, disabled)
except OSError as e:
sys.stderr.write(f"could not update the recovery record ({e}), so nothing was "
f"changed on the TV: without it, restore.sh would leave what "
f"--fix disables off.\n")
return 2
fixing = True
code = fix(dev, listed, back_on, home_drifted, preferred)
except SystemExit as e:
# recovery.query_packages stops with its own message when the TV answers no package at all:
# the TV could not be read, so exit 2, not the 1 Python gives that message. Under --fix the
# exits keep their code, as above.
# the TV could not be read, so exit 2, not the 1 Python gives that message. find_record and
# load_record do the same for a recovery record --fix cannot use. Under --fix the exits keep
# their code, as above.
if fixing:
raise
sys.stderr.write(f"{e}\n")
+49 -16
View File
@@ -11,8 +11,9 @@ before. Both scripts now go through this record: `disable.sh` writes it before t
reaches the TV, and `restore.sh` replays it.
Records live in ~/.cleanyourtv/, one per TV and list. The first one wins: a retry after a
half-applied list must not record the broken state as the original. A later run only adds the names
the list gained since then, with their state before this list disabled them.
half-applied list must not record the broken state as the original. A later run, and check.py --fix,
only add the names the list gained since then, with their state before this list disabled them, and
record a package that was absent then and is installed and enabled now as enabled.
"""
import hashlib
import ipaddress
@@ -195,7 +196,8 @@ def load_record(path):
# would lose that: restore would then enable every listed package, and a new disable.sh run would
# record the TV after cleanup as the original. So the chooser and that one entry are dropped here,
# which reads the record the way save() writes it now. Any other bad name is still refused below,
# and save() writes the record back this way only when the list has gained names.
# and extend_record() (from save() and check.py --fix) writes the record back this way only when the
# list has gained names or a package recorded absent has since arrived.
home = record.get("home")
if isinstance(home, str) and HOME_RE.fullmatch(home) and is_chooser(home):
record["packages"].pop(home.split("/", 1)[0], None)
@@ -308,18 +310,50 @@ def package_state(name, installed, disabled):
else "enabled")
def extend_record(path, record, names, installed, disabled):
"""Bring an existing record up to date before the list is applied to the TV again.
save() runs this for disable.sh, and check.py --fix runs it before it disables anything, because
restore puts back only what the record holds. The list may have been edited since the record was
taken. Entries already in the record are never overwritten (a retry must not record the broken
state as the original), but names it does not hold yet have not been touched by this list, so
their state now is their state before cleanup. So is the state of a package recorded as absent
that is installed and enabled now: an update or the Play Store put it there after the record was
taken. Without them, restore would leave those packages disabled for good. One that is installed
but disabled stays absent, as nobody can say what disabled it, and restore leaves both alone.
The file is written only when something changed. Returns the names added and the ones that
arrived.
"""
states = record["packages"]
arrived = [name for name in names if states.get(name) == "absent"
and package_state(name, installed, disabled) == "enabled"]
added = [name for name in names if name not in states]
for name in added:
states[name] = package_state(name, installed, disabled)
for name in arrived:
states[name] = "enabled"
if not added and not arrived:
return added, arrived
write_record(path, record)
if added:
print(f"added {len(added)} names the list gained since then to the recovery record: "
f"{', '.join(added)}")
if arrived:
print(f"recorded {len(arrived)} as enabled, absent when the record was taken and installed "
f"since: {', '.join(arrived)}")
print("every other entry keeps the state from before the first run.")
return added, arrived
def save(list_path, host):
path = find_record(list_path, host)
if path.exists():
record = load_record(path)
packages = read_list(list_path)
# The list may have been edited since the record was taken. Entries already in the record are
# never overwritten (a retry must not record the broken state as the original), but names it
# does not hold yet have not been touched by this list, so their state now is their state
# before cleanup. Without them, restore would leave those packages disabled for good.
added = [name for name in packages if name not in record["packages"]]
print(f"keeping the recovery record from {record.get('created')}: {path}")
if not added:
# Only a name the record does not hold, or holds as absent, can change it (extend_record), and
# only then is the TV asked.
if all(record["packages"].get(name, "absent") != "absent" for name in packages):
print("nothing was recorded again: it holds the state from before the first run.")
return 0
dev = connect()
@@ -327,11 +361,9 @@ def save(list_path, host):
installed, disabled = query_packages(dev)
finally:
dev.close()
for name in added:
record["packages"][name] = package_state(name, installed, disabled)
write_record(path, record)
print(f"added {len(added)} names the list gained since then, entries already recorded "
f"are unchanged: {', '.join(added)}")
added, arrived = extend_record(path, record, packages, installed, disabled)
if not added and not arrived:
print("nothing was recorded again: it holds the state from before the first run.")
return 0
packages = read_list(list_path)
@@ -444,8 +476,9 @@ def restore_commands(list_path, stock, host):
commands.append(f"settings put global {key} {value}")
else:
sys.stderr.write(f" skipping {key}: the record holds {value!r}, not a number\n")
# disable.sh adds names the list gained to the record before disabling them, so a name still
# missing here was added after the last disable run and never disabled by it: leave it alone.
# disable.sh and check.py --fix add names the list gained to the record before disabling them, so
# a name still missing here was added after the last of those runs and never disabled by it:
# leave it alone.
edited = [name for name in packages if name not in states]
if edited:
sys.stderr.write(f"not in the record, leaving alone: {', '.join(edited)}\n")