{"content":"   1→#!/usr/bin/env bash\n   2→# Build the launcher APK and install it on the TV as the default home screen.\n   3→set -euo pipefail\n   4→cd \"$(dirname \"$0\")\"\n   5→: \"${TV_HOST:?set TV_HOST to the TV IP address, e.g. TV_HOST=192.168.1.50 $0}\"\n   6→TOOLS=\"$(cd ../tools && pwd)\"   # absolute, so the venv's Python doesn't warn about \"../\" in its prefix\n   7→PY=$TOOLS/venv/bin/python\n   8→[ -x \"$PY\" ] || PY=$TOOLS/venv/Scripts/python.exe\n   9→export MSYS_NO_PATHCONV=1\n  10→# Below minSdk, `pm install` refuses the APK with INSTALL_FAILED_OLDER_SDK - and only after gradle has\n  11→# spent a minute building and b64push has pushed the APK to the TV (#576). Ask the TV first. The\n  12→# number comes from the build file, so it follows the launcher instead of a hard-coded copy here.\n  13→MIN_SDK=$(sed -n 's/^[[:space:]]*minSdk[[:space:]][[:space:]]*\\([0-9]\\{1,3\\}\\)[[:space:]]*$/\\1/p' app/build.gradle 2>/dev/null | head -n 1 || true)\n  14→TV_SDK=$(\"$PY\" -I \"$TOOLS/tv.py\" 'getprop ro.build.version.sdk' 2>/dev/null | tr -dc '0-9' || true)\n  15→if [ -n \"$MIN_SDK\" ] && [ -n \"$TV_SDK\" ] && [ \"$TV_SDK\" -lt \"$MIN_SDK\" ]; then\n  16→  echo \"ERROR: this TV runs SDK $TV_SDK and the launcher is built with minSdk $MIN_SDK (app/build.gradle),\" >&2\n  17→  echo \"so pm install would answer INSTALL_FAILED_OLDER_SDK. Nothing was built, pushed or disabled.\" >&2\n  18→  echo \"The debloat half of the repo still applies to that set, from its own home screen; the launcher\" >&2\n  19→  echo \"half would need your own build with a lower minSdk (docs/TROUBLESHOOTING.md).\" >&2\n  20→  exit 1\n  21→fi\n  22→# macOS: java_home or Homebrew; Linux: the java on PATH.\n  23→# `/usr/libexec/java_home -v 17` means \"17 or newer\", so on a Mac whose only JDK is newer it prints\n  24→# that one and exits 0, and the Homebrew fallback below never runs. Gradle 8.9 then stops with\n  25→# \"Unsupported class file major version\" (68 for JDK 24) before it reads a line of this project.\n  26→# Homebrew's openjdk@17 is keg-only and is never registered where java_home would find it, so check\n  27→# the version it answers with and fall through when it is not a 17. `java -version` goes to a\n  28→# variable rather than through `grep -q`, whose early exit can SIGPIPE the JVM under `set -o pipefail`.\n  29→if [ -z \"${JAVA_HOME:-}\" ]; then\n  30→  JH=\"$(/usr/libexec/java_home -v 17 2>/dev/null || true)\"\n  31→  if [ -n \"$JH\" ]; then\n  32→    case \"$(\"$JH/bin/java\" -version 2>&1 || true)\" in *'version \"17.'*) ;; *) JH=\"\" ;; esac\n  33→  fi\n  34→  if [ -z \"$JH\" ] && [ -d /opt/homebrew/opt/openjdk@17 ]; then JH=/opt/homebrew/opt/openjdk@17; fi\n  35→  if [ -z \"$JH\" ]; then JH=\"$(java -XshowSettings:properties -version 2>&1 | awk '/java.home/ {print $3}')\"; fi\n  36→  JAVA_HOME=\"$JH\"\n  37→fi\n  38→export JAVA_HOME\n  39→# Android Studio's default SDK location on macOS, then on Linux, then Homebrew's command-line tools.\n  40→export ANDROID_HOME=\"${ANDROID_HOME:-$(for d in ~/Library/Android/sdk ~/Android/Sdk; do [ -d \"$d\" ] && echo \"$d\" && break; done)}\"\n  41→export ANDROID_HOME=\"${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}\"\n  42→export PATH=\"$JAVA_HOME/bin:$PATH\"\n  43→[ -f local.properties ] || echo \"sdk.dir=$ANDROID_HOME\" > local.properties\n  44→./gradlew assembleRelease -q --console=plain\n  45→# Which app owns Home differs by build (com.google.android.tvlauncher on Android TV,\n  46→# com.google.android.apps.tv.launcherx on newer Google TV), so ask the TV instead of guessing.\n  47→# Only a launcher we recognise is accepted: the answer can also be Android's chooser (\"android\"),\n  48→# the setup wizard, or another third-party launcher, none of which should be disabled by accident.\n  49→# Pass STOCK_LAUNCHER=<package> to name it yourself, or STOCK_LAUNCHER=none to disable nothing.\n  50→KNOWN_STOCK_LAUNCHERS=\"com.google.android.tvlauncher com.google.android.apps.tv.launcherx com.google.android.leanbacklauncher com.mitv.tvhome\"\n  51→if [ -z \"${STOCK_LAUNCHER:-}\" ]; then\n  52→  HOME_PKG=$(\"$PY\" -I ../tools/tv.py 'cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME' \\\n  53→    | tail -n 1 | tr -d '\\r' | cut -d/ -f1) || {\n  54→    echo \"ERROR: could not ask the TV which launcher owns Home. Check TV_HOST and the debugging prompt on the TV,\" >&2\n  55→    echo \"or pass STOCK_LAUNCHER=<package> yourself.\" >&2; exit 1; }\n  56→  case \" $KNOWN_STOCK_LAUNCHERS \" in\n  57→    *\" $HOME_PKG \"*) STOCK_LAUNCHER=$HOME_PKG ;;\n  58→    *)\n  59→      if [ \"$HOME_PKG\" = com.gathin.tvhome ]; then\n  60→        STOCK_LAUNCHER=none; echo \"Home already belongs to this launcher; nothing will be disabled.\"\n  61→      elif [ \"$HOME_PKG\" = android ]; then\n  62→        # Android's chooser: two or more launchers tie and no default is set, which is where a TV lands\n  63→        # after tools/restore.sh followed by a re-applied list. Take the one known stock launcher among\n  64→        # the HOME claimants, if there is exactly one.\n  65→        CLAIMANTS=$(\"$PY\" -I ../tools/tv.py 'cmd package query-activities --brief -a android.intent.action.MAIN -c android.intent.category.HOME' \\\n  66→          | tr -d '\\r' | grep -o '[A-Za-z0-9_.]*/' | tr -d / | sort -u)\n  67→        FOUND=\"\"; N=0\n  68→        for k in $KNOWN_STOCK_LAUNCHERS; do\n  69→          if printf '%s\\n' \"$CLAIMANTS\" | grep -qx \"$k\"; then FOUND=$k; N=$((N + 1)); fi\n  70→        done\n  71→        if [ \"$N\" -eq 1 ]; then\n  72→          STOCK_LAUNCHER=$FOUND; echo \"Home is Android's chooser; the stock launcher among the candidates is $STOCK_LAUNCHER.\"\n  73→        else\n  74→          echo \"ERROR: Home is Android's launcher chooser and $N known stock launchers claim Home: pick a default on the TV first,\" >&2\n  75→          echo \"or run again with STOCK_LAUNCHER=<package to disable> or STOCK_LAUNCHER=none.\" >&2; exit 1\n  76→        fi\n  77→      else\n  78→        echo \"ERROR: Home currently resolves to '${HOME_PKG:-nothing}', which is not a stock launcher this script knows.\" >&2\n  79→        echo \"  android                                  = Android's launcher chooser (two launchers tie); pick a default on the TV first\" >&2\n  80→        echo \"  com.google.android.tungsten.setupwraith  = Google TV's setup wizard; see README, Troubleshooting\" >&2\n  81→        echo \"  anything else                            = another launcher is the default\" >&2\n  82→        echo \"Run again with STOCK_LAUNCHER=<package to disable> or STOCK_LAUNCHER=none.\" >&2; exit 1\n  83→      fi ;;\n  84→  esac\n  85→fi\n  86→echo \"Stock launcher: $STOCK_LAUNCHER\"\n  87→HOME_QUERY='cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME'\n  88→if [ \"$STOCK_LAUNCHER\" = none ]; then DISABLE_STOCK=\"echo stock launcher left alone\"\n  89→else DISABLE_STOCK=\"pm disable-user --user 0 $STOCK_LAUNCHER\"; fi\n  90→APK=app/build/outputs/apk/release/app-release.apk\n  91→\"$PY\" -I ../tools/b64push.py \"$APK\" /data/local/tmp/tvhome.apk\n  92→# Install on its own: in the batch below a failed install (e.g. \"not enough space\" on a full /data)\n  93→# would not stop the stock launcher from being disabled.\n  94→OUT=$(\"$PY\" -I ../tools/tv.py 'pm install -r /data/local/tmp/tvhome.apk; rm -f /data/local/tmp/tvhome.apk')\n  95→echo \"$OUT\"\n  96→case \"$OUT\" in\n  97→  *Success*) ;;\n  98→  *INSTALL_FAILED_OLDER_SDK*)\n  99→    # The pre-flight above missed it (a TV whose getprop did not answer, or an APK built elsewhere):\n 100→    # name the real cause instead of leaving the raw Failure line to be read as an ADB problem.\n 101→    echo \"ERROR: pm install refused the APK as too new for this set (INSTALL_FAILED_OLDER_SDK): the TV runs an older SDK than the launcher's minSdk ${MIN_SDK:-in app/build.gradle}.\" >&2\n 102→    echo \"The stock launcher was left untouched. Read both numbers and see docs/TROUBLESHOOTING.md.\" >&2\n 103→    exit 1 ;;\n 104→  *) echo \"ERROR: pm install failed; the stock launcher was left untouched.\" >&2; exit 1 ;;\n 105→esac\n 106→# Start the launcher on its own, before the stock Home is touched. In the batch below a start that\n 107→# fails only prints an \"Error:\" line, and the stock launcher would be disabled anyway, leaving the TV\n 108→# with no usable Home (#239). \"Status: ok\" from `am start -W` means the activity really came up;\n 109→# the HOME resolution further down only names a component, it does not prove the activity starts.\n 110→START_OUT=$(\"$PY\" -I ../tools/tv.py 'am start -W -a android.intent.action.MAIN -c android.intent.category.HOME -n com.gathin.tvhome/.MainActivity')\n 111→echo \"$START_OUT\"\n 112→# Both halves matter: some builds answer \"Status: ok\" for a task that was merely brought to the\n 113→# front, and an activity that cannot start still prints \"Error: Activity not started\".\n 114→if ! printf '%s' \"$START_OUT\" | grep -qF 'Status: ok' || printf '%s' \"$START_OUT\" | grep -qi '^Error'; then\n 115→  echo \"ERROR: the new launcher did not start (see above); $STOCK_LAUNCHER was left enabled.\" >&2\n 116→  exit 1\n 117→fi\n 118→printf '%s\\n' \\\n 119→  \"pm grant com.gathin.tvhome android.permission.ACCESS_FINE_LOCATION 2>/dev/null; echo location-granted\" \\\n 120→  \"cmd package compile -m speed -f com.gathin.tvhome\" \\\n 121→  \"$DISABLE_STOCK\" \\\n 122→  \"cmd package set-home-activity --user 0 com.gathin.tvhome/.MainActivity\" \\\n 123→  | \"$PY\" -I ../tools/tv.py --batch\n 124→\n 125→# Home's owner, or an empty string when the TV cannot be reached (pipefail would otherwise make a\n 126→# lost connection abort the script with no explanation).\n 127→query_home() {\n 128→  \"$PY\" -I ../tools/tv.py \"$HOME_QUERY\" 2>/dev/null | tail -n 1 | tr -d '\\r' || true\n 129→}\n 130→\n 131→# Put the stock launcher back and check that it took. A TV whose new Home does not start can only be\n 132→# rescued over ADB, so the commands go into a local file as well: when it is the connection that\n 133→# broke, the terminal that ran this script may never come back.\n 134→rollback_home() {\n 135→  if [ \"$STOCK_LAUNCHER\" = none ]; then\n 136→    echo \"ERROR: Home is not the new launcher, and no stock launcher was disabled; nothing to roll back.\" >&2\n 137→    return 1\n 138→  fi\n 139→  \"$PY\" -I ../tools/tv.py \"pm enable --user 0 $STOCK_LAUNCHER\" >/dev/null 2>&1 || true\n 140→  # set-home-activity needs pkg/activity on Android 9, so resolve the component on the TV first.\n 141→  \"$PY\" -I ../tools/tv.py \"cmd package set-home-activity --user 0 \\$(cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME $STOCK_LAUNCHER | tail -n 1)\" >/dev/null 2>&1 || true\n 142→  BACK=$(query_home)\n 143→  case \"$BACK\" in\n 144→    \"$STOCK_LAUNCHER\"/*) echo \"$STOCK_LAUNCHER owns Home again.\" >&2; return 0 ;;\n 145→  esac\n 146→  REC=\"${HOME:-$PWD}/cleanyourtv-handover-$(printf '%s' \"$TV_HOST\" | tr -c 'A-Za-z0-9._-' '_').txt\"\n 147→  {\n 148→    echo \"# cleanyourtv: the launcher handover on $TV_HOST did not finish. From the repo root:\"\n 149→    echo \"TV_HOST=$TV_HOST tools/tv.py 'pm enable --user 0 $STOCK_LAUNCHER'\"\n 150→    echo \"TV_HOST=$TV_HOST tools/tv.py 'cmd package set-home-activity --user 0 \\$(cmd package resolve-activity --brief -a android.intent.action.MAIN -c android.intent.category.HOME $STOCK_LAUNCHER | tail -n 1)'\"\n 151→    echo \"# then check: TV_HOST=$TV_HOST tools/tv.py '$HOME_QUERY'\"\n 152→  } > \"$REC\" 2>/dev/null || REC=\"\"\n 153→  echo \"ERROR: recovery is incomplete; Home still resolves to '${BACK:-nothing}'.\" >&2\n 154→  if [ -n \"$REC\" ]; then echo \"The commands that finish it are in $REC; the TV still needs ADB for them.\" >&2\n 155→  else echo \"Re-run once the TV answers: pm enable --user 0 $STOCK_LAUNCHER, then set its Home activity.\" >&2; fi\n 156→  return 1\n 157→}\n 158→\n 159→# Order matters: the new launcher is started before the stock one is disabled, and the home\n 160→# preference is set after. Android drops the preference whenever the set of HOME apps changes, and\n 161→# on Google TV 14 the next candidate is the setup wizard's RecoveryActivity, which crash-loops\n 162→# system_server (see examples/xiaomi-tv-a-pro-2026). So check where Home ended up, and put the\n 163→# stock launcher back if it is not ours.\n 164→HOME_NOW=$(query_home)\n 165→case \"$HOME_NOW\" in\n 166→  com.gathin.tvhome/*) echo \"Home now resolves to $HOME_NOW\" ;;\n 167→  *)\n 168→    if [ -z \"$HOME_NOW\" ]; then\n 169→      echo \"ERROR: the TV stopped answering while Home was being switched, so the outcome is unknown.\" >&2\n 170→    else\n 171→      echo \"ERROR: Home resolves to $HOME_NOW, not to the new launcher.\" >&2\n 172→    fi\n 173→    rollback_home || true\n 174→    if [ -n \"$HOME_NOW\" ]; then\n 175→      echo \"On Google TV this usually means com.google.android.tungsten.setupwraith also claims Home: add it to\" >&2\n 176→      echo \"your debloat list (see README, Troubleshooting), apply the list, then run this script again.\" >&2\n 177→    fi\n 178→    exit 1 ;;\n 179→esac\n 180→\n 181→# Optional accessibility services. Off unless asked for, and Android switches them off again on\n 182→# every reinstall, so pass the flag each time you run this script.\n 183→#   INPUT_KEY_SERVICE=1   the remote's Input button (KEYCODE_TV or TV_INPUT) opens the input switcher from any\n 184→#                         app; Google TV needs this once the stock launcher is gone (InputKeyService).\n 185→#                         It also handles input_key_keycodes and app_keys (config.xml or a theme; a YouTube button)\n 186→#   HOME_KEY_SERVICE=1    remotes whose Home button sends KEYCODE_ALL_APPS instead of HOME, e.g. Xiaomi\n 187→#                         sticks: the key is routed to the launcher (HomeKeyService)\n 188→#   RESTORE_KEY_SERVICES=1  grants WRITE_SECURE_SETTINGS, so that with restore_key_services (config.xml or a\n 189→#                         theme) Home puts these helpers back after a force stop of the launcher\n 190→# TCL firmware (TclAppBoot) refuses to bind an app's accessibility service while the app is not running unless it\n 191→# holds the AUTO_START app-op, so grant it first; other TVs do not know the op and ignore it. A service that is\n 192→# already listed was refused while the APK was replaced, and binds again only once the list changes, so clear it,\n 193→# switch accessibility off and write it back (as tools/theme.sh does).\n 194→enable_service() {   # appends to enabled_accessibility_services without dropping other services\n 195→  svc=com.gathin.tvhome/.$1\n 196→  \"$PY\" -I ../tools/tv.py \"cmd appops set com.gathin.tvhome AUTO_START allow 2>/dev/null; s=\\$(settings get secure enabled_accessibility_services); case \\\"\\$s\\\" in *$1*) settings put secure enabled_accessibility_services ''; settings put secure accessibility_enabled 0; sleep 1; settings put secure enabled_accessibility_services \\\"\\$s\\\" ;; null|'') settings put secure enabled_accessibility_services $svc ;; *) settings put secure enabled_accessibility_services \\\"\\$s:$svc\\\" ;; esac; settings put secure accessibility_enabled 1; echo $1 enabled\"\n 197→}\n 198→if [ \"${INPUT_KEY_SERVICE:-0}\" = 1 ]; then enable_service InputKeyService; fi\n 199→if [ \"${HOME_KEY_SERVICE:-0}\" = 1 ]; then enable_service HomeKeyService; fi\n 200→if [ \"${RESTORE_KEY_SERVICES:-0}\" = 1 ]; then\n 201→  \"$PY\" -I ../tools/tv.py \"pm grant com.gathin.tvhome android.permission.WRITE_SECURE_SETTINGS && echo restore-granted\"\n 202→fi\n 203→echo \"Installed. Press Home on the remote to see it.\"\n 204→","renderer":{"type":"code","context":{"language":"bash"}}}