Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ tools/theme.sh themes/aurora # push a theme; --r
| [`tools/adb-closed.py`](tools/adb-closed.py) | Checks that the TV's network ADB port is closed once debugging is off: exits 0 when the TV refuses the connection, 1 while it is still open, 2 when nothing answered or the check itself could not run. No `nc` needed. |
| [`tools/philips-api.py`](tools/philips-api.py) | Philips only: checks that the JointSpace control API Home Assistant uses still answers, before and after a debloat (`tools/venv/bin/python -I tools/philips-api.py <tv ip>`). Port 1926 over HTTPS first, then 1925; reads only what answers without pairing. |
| [`tools/privacy.py`](tools/privacy.py) | Audits the TV for voice, ACR and telemetry candidates and prints the evidence for each; `--write` turns the findings into a list `disable.sh` and `restore.sh` already understand. |
| [`tools/profile.py`](tools/profile.py) | A household profile: `capture` what the master unit has and disables, `check` another unit against it, and `sync --source` install the missing apps and mirror the disabled set. Exit codes as `check.py`; nothing is uninstalled. [Guide](docs/profiles.md). |
| [`tools/theme.sh`](tools/theme.sh), [`theme_restart.py`](tools/theme_restart.py) | Push a folder from [`themes/`](themes/) to the launcher and restart Home. |
| [`tools/manage.py`](tools/manage.py) | List every add-on, theme and switch with its screenshot, see what is on the TV, and install, update or remove one by running its creator's own script. |
| [`tools/bench.py`](tools/bench.py) | Measures the launcher that answers HOME: key latency, how far the focus moved, janky frames, cold start, return to Home, and the idle CPU and PSS it costs. Changes nothing on the TV and writes the raw per-key numbers to JSON; no extra install. |
Expand Down
8 changes: 8 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ Things that bit us or contributors, so they don't bite you. Commands use `tools/
as `_adb-tls-connect._tcp` while it is open: note it before switching Wireless debugging off, and
run `tools/adb-closed.py <tv ip> <port>` straight after, before the reboot. After the reboot the old
number reads closed either way, because Wireless debugging takes a new port each time it starts.
- **ADB never stays on by itself.** Wireless debugging is not a persistent setting: it goes off with every
Wi-Fi drop, including each boot, and takes a new port each time it comes back, so there is nothing to
pin. On an unrooted Google TV the way to get it back without a person is an app that re-enables it on
boot, with the permission that writes the setting granted to it once over ADB:
`TV_HOST=<ip> tools/venv/bin/python -I tools/tv.py 'pm grant <package> android.permission.WRITE_SECURE_SETTINGS'`,
plus an exemption from battery optimisation. `com.tpn.adbautoenable` and tvQuick Actions Pro both do it;
wait one reboot before believing it. Remember what an always-on ADB means: port 5555 is plaintext, and
anything on the LAN that has the key gets a shell. Leave it off and turn it on for the job.
- **After `adb_enabled` was switched off from the shell, the menu says USB debugging is on and port 5555
stays closed.** On an [Aurzen BOOM Air projector (BOOMAIR, Android 11, `RTMA.250416.043`)](https://github.com/Gathin23/cleanyourtv/issues/2632)
`settings put global adb_enabled 0` had closed the port as it should, and when ADB was wanted again
Expand Down
76 changes: 76 additions & 0 deletions docs/profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Profiles: one setup, several TVs

A profile is the list of what a household's TVs should look like: the apps the master unit has, and
the packages it has disabled. Capture it once from the unit that is already set up, then check or
converge any other unit of the same model against it. Two Chromecast with Google TV 4K in one house
end up with the same apps and the same cleanup, without doing the work twice.

**The master is the unit you already set up.** Whatever its third-party apps and disabled packages
are, they become the profile.

## Capture the master

```bash
TV_HOST=<master ip> tools/venv/bin/python -I tools/profile.py capture profiles/sabrina
```

That writes three files:

| File | What it holds |
|---|---|
| `apps.txt` | The master's third-party packages, one per line: what the other unit should have. |
| `debloat.txt` | The master's disabled packages, in the format [`tools/disable.sh`](../tools/disable.sh) and [`tools/restore.sh`](../tools/restore.sh) already take. |
| `profile.json` | Model, device, SDK and capture date, so a profile for the wrong set is obvious. |

Capture sends no command that changes the TV.

## Check any unit against it

```bash
TV_HOST=<tv ip> tools/venv/bin/python -I tools/profile.py check profiles/sabrina
```

Exit `0` when the unit matches, `1` when it drifted, `2` when the run could not check at all, the
same three codes [`tools/check.py`](../tools/check.py) uses. A drifted report names the packages:
`missing` (the profile has it, this unit does not), `not in the profile` (this unit has an app the
master does not), `should be disabled`, and `disabled but the profile installs it`. A check changes
nothing.

## Converge a second unit to the master

```bash
TV_HOST=<target ip> tools/venv/bin/python -I tools/profile.py sync profiles/sabrina --source <master ip>
```

`TV_HOST` is the unit that changes, `--source` the master, as with every other tool in `tools/`.
Without `--apply` the run prints the plan and exits `1`; with it, the run:

- installs the profile's apps the target is missing, pulled from the master with `pm path` and
installed with `pm install -r`, or for a split APK in one `pm install-create` / `install-write` /
`install-commit` session. A package whose APK the master's shell cannot read, or whose install
fails, is listed for a manual install and its files are left in a temporary folder;
- disables the packages the profile disables, and enables the ones the master has enabled;
- prints the undo commands for everything it disabled;
- reports the apps that are only on the target and leaves them alone.

Nothing is uninstalled, and no package the profile does not name is touched.

## What a profile does not carry

App data, accounts, DRM state and Wi-Fi credentials stay on each unit; this is a package list, not a
backup. Launcher colours, dock and layout live in a [theme](../themes/README.md), which
[`tools/theme.sh`](../tools/theme.sh) pushes to any unit; a profile and a theme pushed together are
the same home screen on both TVs.

**Paid and account-bound apps do not belong in a profile.** An APK copied from another unit carries no
licence: Play checks the purchase against the Google account signed in on *that* unit, so a paid app
arrives and then refuses to run, as tvQuick Actions Pro does. Install those from the Play Store on each
unit with the account that bought them, and keep them out of `apps.txt` — a line commented out there is
read as "not part of this profile" and is never installed. The same applies to a patched or re-signed
package built on one unit: `app.revanced.android.gms` is left out of `profiles/master/` for that reason.

## After a firmware update

A firmware update re-enables packages and can hand Home back to the stock launcher. Run `check` to
see it, then `sync --source` from the master, or `tools/venv/bin/python -I tools/check.py --fix` on a single
unit that holds its own list.
24 changes: 24 additions & 0 deletions profiles/master/apps.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# third-party apps captured from Chromecast on 2026-11-01; tools/profile.py reads this file
app.morphe.manager
# app.revanced.android.gms patched Play Services: installing it over the system one can break
# Play Services and the account, so it is not part of the sync
com.amazon.amazonvideo.livingroom
com.apkupdater
com.brave.browser
com.cybercat.adbappcontrol.tv
com.gathin.tvhome
com.looker.droidify
com.phlox.tvwebbrowser
com.tpn.adbautoenable
de.twokit.castbrowser
# dev.vodik7.tvquickactions paid app: an APK copied from here carries no licence, so
# install it from the Play Store on each unit with the buying account
dnsfilter.android
it.mediaset.infinitytv
it.rainet.androidtv
me.timschneeberger.shizustore
moe.shizuku.privileged.api
org.fossify.filemanager
org.jellyfin.androidtv
org.localsend.localsend_app
org.smarttube.beta
17 changes: 17 additions & 0 deletions profiles/master/debloat.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# disabled packages captured from Chromecast on 2026-11-01; tools/profile.py reads this file
com.android.printspooler
com.android.tv.feedbackconsent
com.google.android.adservices.api
com.google.android.apps.education.cast2class
com.google.android.apps.tv.dreamx
com.google.android.apps.tv.launcherx
com.google.android.apps.tv.netoscope
com.google.android.backdrop
com.google.android.ext.adservices.api
com.google.android.federatedcompute
com.google.android.feedback
com.google.android.marvin.talkback
com.google.android.ondevicepersonalization.services
com.google.android.tungsten.setupwraith
com.google.android.tv.remotecontrol.logging
com.google.android.tvrecommendations
8 changes: 8 additions & 0 deletions profiles/master/profile.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"apps": 21,
"captured": "2026-11-01",
"device": "sabrina",
"disabled": 16,
"model": "Chromecast",
"sdk": "34"
}
221 changes: 221 additions & 0 deletions tests/profile_fake.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"""A simulated TV for the profile tests: tools/tv.py replaced by a script that reads JSON state.

One state file per host, so a test can hold a master and a target at the same time: the fake picks
the file from TV_HOST, which is exactly how tools/profile.py addresses the two units.
"""

import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import List, Union
import tempfile

ROOT = Path(__file__).resolve().parents[1]
MASTER = "192.168.1.50"
TARGET = "192.168.1.60"

FAKE_TV = r'''
import json
import os
import sys

STATES = os.environ["FAKE_TV_STATES"]


def path(host):
return os.path.join(STATES, host.replace(":", "_") + ".json")


def load():
with open(path(os.environ["TV_HOST"])) as f:
return json.load(f)


def save(state):
with open(path(os.environ["TV_HOST"]), "w") as f:
json.dump(state, f)


def answer(state, command):
if command == "pm list packages":
return "".join(f"package:{name}\n" for name in state["installed"])
if command == "pm list packages -3":
return "".join(f"package:{name}\n" for name in state["installed"] if name in state["third_party"])
if command == "pm list packages -d":
return "".join(f"package:{name}\n" for name in state["disabled"])
if command.startswith("pm path "):
return "".join(f"package:{p}\n" for p in state["paths"].get(command.split()[-1], []))
if command.startswith("getprop "):
return state.get("props", {}).get(command.split()[-1], "") + "\n"
return ""


def apply(state, command):
state["commands"].append(command)
words = command.split()
if command.startswith("pm install-create"):
state["session"] = {"id": "7", "pkgs": []}
return "Success: created install session [7]\n"
if command.startswith("pm install-write "):
pkg = state["apks"].get(words[-1])
if pkg and pkg not in state["session"]["pkgs"]:
state["session"]["pkgs"].append(pkg)
return "Success: streamed\n"
if command.startswith("pm install-commit "):
if state.get("commit_fails"):
return "Failure [INSTALL_FAILED_INVALID_APK]\n"
for pkg in state["session"]["pkgs"]:
if pkg not in state["installed"]:
state["installed"].append(pkg)
state["third_party"].append(pkg)
state["session"] = {"id": "", "pkgs": []}
return "Success\n"
if command.startswith("pm install-abandon "):
state["session"] = {"id": "", "pkgs": []}
return "Success\n"
if command.startswith("pm disable-user --user 0 "):
name = command.split()[-1]
if name not in state["disabled"]:
state["disabled"].append(name)
elif command.startswith("pm enable --user 0 "):
name = command.split()[-1]
if name in state["disabled"]:
state["disabled"].remove(name)
elif command.startswith("pm install -r "):
name = state["apks"].get(command.split()[-1])
if name and name not in state["installed"]:
state["installed"].append(name)
state["third_party"].append(name)
return ""


class Device:
def shell(self, command, read_timeout_s=None):
current = load()
words = command.split()
if words and words[0] == "pm" and len(words) > 1 and words[1] in (
"disable-user", "enable", "install", "install-create", "install-write",
"install-commit", "install-abandon"):
out = apply(current, command)
save(current)
return out
return answer(current, command)

def close(self):
pass


def connect():
host = os.environ["TV_HOST"]
if not os.path.exists(path(host)):
raise ConnectionError("could not connect to " + host)
return Device()


def main():
args = sys.argv[1:]
state = load()
if not args:
return 2
if args[0] == "--batch":
for line in sys.stdin:
line = line.strip()
if line and not line.startswith("#"):
apply(state, line)
save(state)
return 0
if args[0] == "--pull":
remote, local = args[1], args[2]
if remote not in state["files"]:
sys.stderr.write("no such file on the TV: " + remote + "\n")
return 1
with open(local, "w") as f:
f.write(state["files"][remote])
return 0
if args[0] == "--push":
local, remote = args[1], args[2]
with open(local) as f:
state["files"][remote] = f.read()
save(state)
return 0
if args[0] == "--probe":
for key in ("ro.product.model", "ro.product.device", "ro.build.version.sdk"):
print(key + " = " + state.get("props", {}).get(key, ""))
return 0
command = " ".join(args)
words = command.split()
if words and words[0] == "pm" and len(words) > 1 and words[1] in (
"disable-user", "enable", "install", "install-create", "install-write",
"install-commit", "install-abandon"):
out = apply(state, command)
save(state)
sys.stdout.write(out)
return 0
sys.stdout.write(answer(state, command))
return 0


if __name__ == "__main__":
sys.exit(main())
'''

STS = "com.google.android.tvlauncher"


def tv_state(installed, third_party, disabled=(), paths=None, props=None):
"""One simulated unit: the packages it has, which are third-party, and their APK paths."""
return {
"installed": list(installed),
"third_party": list(third_party),
"disabled": list(disabled),
"paths": dict(paths or {}),
"props": dict(props or {"ro.product.model": "Chromecast", "ro.product.device": "sabrina",
"ro.build.version.sdk": "34"}),
"files": {},
"apks": {},
"session": {"id": "", "pkgs": []},
"commands": [],
}


class Harness:
"""A temp repo holding tools/profile.py over the fake TV, plus one state file per unit."""

def __init__(self, case):
tmp = tempfile.TemporaryDirectory()
case.addCleanup(tmp.cleanup)
self.base = Path(tmp.name)
self.repo = self.base / "repo"
self.tools = self.repo / "tools"
self.tools.mkdir(parents=True)
(self.tools / "tv.py").write_text(FAKE_TV)
self.states = self.base / "states"
self.states.mkdir()
self.home = self.base / "home"
self.home.mkdir()
self.profile = self.base / "profiles" / "sabrina"

def put(self, host, state):
(self.states / (host.replace(":", "_") + ".json")).write_text(json.dumps(state))

def state(self, host):
return json.loads((self.states / (host.replace(":", "_") + ".json")).read_text())

def commands(self, host):
return self.state(host)["commands"]

def run(self, *args, host=MASTER):
shutil.copy2(ROOT / "tools" / "profile.py", self.tools / "profile.py")
shutil.copy2(ROOT / "tools" / "recovery.py", self.tools / "recovery.py")
env = dict(os.environ, TV_HOST=host, FAKE_TV_STATES=str(self.states), HOME=str(self.home))
return subprocess.run([sys.executable, str(self.tools / "profile.py"), *args],
cwd=self.repo, env=env, capture_output=True, text=True, timeout=120)


def package_lines(path: Union[str, Path]) -> List[str]:
"""The package lines of a list, read the way tools/disable.sh reads one."""
return [line.split()[0] for line in Path(path).read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")]
Loading
Loading