Skip to content

Commit 9d3d756

Browse files
authored
feat(androidtv): add Projectivy deployment helper (#12)
## Summary - add a generic Projectivy deployment helper for host or containerized ADB - use the verified Projectivy package and launcher component - add checksum-pinned Android TV app fleet sync with downgrade protection - audit Play-managed packages without sideloading over them - identify offline and unauthorized ADB states with bounded timeouts - document persistent ADB-key and manifest conventions ## Intended fleet split - Play-managed/audited: Projectivy, Plex, YouTube TV, and SeerrTV on Shield/Google TV - pinned sideload: SmartTube and SeerrTV on Amazon Fire TV ## Validation - mocked host and container ADB paths for the Projectivy helper - missing-APK failure handling - repository pre-commit hooks, ShellCheck, shfmt, and CI No site-specific hosts, credentials, endpoints, or secrets are included. The existing authoritative infrastructure source remains unchanged.
1 parent 30729dd commit 9d3d756

4 files changed

Lines changed: 297 additions & 0 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Utility scripts for selfhosted infrastructure management.
77
- `.bash_aliases` — Shell aliases for common operations
88
- `docker_aliases.sh` — Docker Compose wrapper with 1Password secret resolution, fzf integration, Dozzle group management, and tab completion
99
- `tmux_session_picker.sh` — Auto-creates tmux sessions on SSH login with process detection and last-access display
10+
- `projectivy-deploy.sh` — Installs and configures Projectivy Launcher on Android TV over ADB
11+
- `androidtv-app-sync.sh` — Applies checksum-pinned APK updates to reachable Android TV devices
1012

1113
## Conventions
1214
- Bash scripts: POSIX-compatible where possible

‎README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,28 @@ Utility scripts for selfhosted infrastructure management.
1010
- `.bash_aliases` — Shell aliases for common operations
1111
- `docker_aliases.sh` — Docker Compose wrapper with 1Password secret resolution, fzf integration, Dozzle group management, and tab completion
1212
- `tmux_session_picker.sh` — Auto-creates tmux sessions on SSH login with a table showing running processes and last access time
13+
- `projectivy-deploy.sh` — Installs Projectivy Launcher on Android TV over ADB, selects it as home, and supports configuration backup/restore
14+
- `androidtv-app-sync.sh` — Applies checksum-pinned APK updates to reachable Android TV devices without downgrading packages
15+
16+
`androidtv-app-sync.sh` can share one manifest between a timer and manual runs. Keep Play-managed
17+
apps in audit-only mode and pin every sideloaded APK by version and checksum:
18+
19+
```text
20+
# package<TAB>minimum-versionCode<TAB>source<TAB>SHA-256
21+
com.spocky.projengmenu 1 managed -
22+
com.plexapp.android 1 managed -
23+
com.google.android.youtube.tvunplugged 1 managed -
24+
ca.devmesh.seerrtv 1 managed -
25+
smarttube.package.from.approved.apk 123 https://example.invalid/smarttube.apk <64-character-sha256>
26+
ca.devmesh.seerrtv 123 https://example.invalid/seerrtv.apk <64-character-sha256>
27+
```
28+
29+
Use the managed SeerrTV row on Google TV/Shield manifests and the pinned APK row on Fire TV
30+
manifests. SmartTube is always a pinned sideload row. Obtain the package and version from the exact
31+
approved APK (`aapt dump badging app.apk`) rather than assuming stable, beta, and F-Droid IDs match.
32+
33+
The ADB client key must be stored outside an ephemeral container. If a device reports
34+
`unauthorized`, approve that persistent key once on the TV; never rotate keys as a retry strategy.
1335

1436
## Contributors
1537

‎androidtv-app-sync.sh‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
#!/usr/bin/env bash
2+
# Update pinned Android TV APKs when a device is reachable over network ADB.
3+
4+
set -euo pipefail
5+
6+
readonly ADB_CONTAINER="${ADB_CONTAINER:-androidtv-adb}"
7+
8+
usage() {
9+
cat >&2 <<EOF
10+
Usage: $0 <device-host> <manifest.tsv>
11+
12+
Manifest columns (tab-separated): package, minimum versionCode, source, SHA-256.
13+
Use source "managed" and checksum "-" to audit a Play-managed package without
14+
installing it. Otherwise source must be an APK URL with its pinned checksum.
15+
Blank lines and lines beginning with # are ignored. Updates and downgrades must
16+
be represented by a new pinned version and checksum; downgrades are refused.
17+
EOF
18+
exit 2
19+
}
20+
21+
[[ $# -eq 2 ]] || usage
22+
readonly DEVICE_TARGET="$1:5555"
23+
readonly MANIFEST="$2"
24+
[[ -r "$MANIFEST" ]] || {
25+
printf 'ERROR: manifest is not readable: %s\n' "$MANIFEST" >&2
26+
exit 1
27+
}
28+
29+
declare -a adb_cmd
30+
using_container=false
31+
if command -v docker >/dev/null 2>&1 \
32+
&& docker ps --format '{{.Names}}' 2>/dev/null | grep -Fxq "$ADB_CONTAINER"; then
33+
adb_cmd=(docker exec "$ADB_CONTAINER" adb)
34+
using_container=true
35+
elif command -v adb >/dev/null 2>&1; then
36+
adb_cmd=(adb)
37+
else
38+
printf 'ERROR: no running ADB container and no host adb executable\n' >&2
39+
exit 1
40+
fi
41+
42+
run_adb() {
43+
"${adb_cmd[@]}" "$@"
44+
}
45+
46+
tmp_dir="$(mktemp -d)"
47+
declare -a container_apks=()
48+
cleanup() {
49+
run_adb disconnect "$DEVICE_TARGET" >/dev/null 2>&1 || true
50+
if [[ "$using_container" == true && ${#container_apks[@]} -gt 0 ]]; then
51+
docker exec "$ADB_CONTAINER" rm -f "${container_apks[@]}" >/dev/null 2>&1 || true
52+
fi
53+
rm -rf "$tmp_dir"
54+
}
55+
trap cleanup EXIT
56+
57+
if ! timeout 15s "${adb_cmd[@]}" connect "$DEVICE_TARGET" >/dev/null; then
58+
printf 'Device is offline or ADB is unavailable: %s\n' "$DEVICE_TARGET" >&2
59+
exit 75
60+
fi
61+
device_state="$(run_adb -s "$DEVICE_TARGET" get-state 2>&1 || true)"
62+
if [[ "$device_state" == *unauthorized* ]]; then
63+
printf 'ERROR: ADB authorization is missing; approve the persistent client key on the TV\n' >&2
64+
exit 77
65+
fi
66+
if ! timeout 15s "${adb_cmd[@]}" -s "$DEVICE_TARGET" wait-for-device; then
67+
printf 'ERROR: timed out waiting for authorized ADB device %s\n' "$DEVICE_TARGET" >&2
68+
exit 75
69+
fi
70+
device_state="$(run_adb -s "$DEVICE_TARGET" get-state 2>&1 || true)"
71+
[[ "$device_state" == device ]] || {
72+
printf 'ERROR: unexpected ADB state for %s: %s\n' "$DEVICE_TARGET" "$device_state" >&2
73+
exit 1
74+
}
75+
76+
updates=0
77+
audit_failures=0
78+
while IFS=$'\t' read -r package target_version source expected_sha extra; do
79+
[[ -z "$package" || "$package" == \#* ]] && continue
80+
[[ -z "${extra:-}" && "$target_version" =~ ^[0-9]+$ ]] || {
81+
printf 'ERROR: invalid manifest row for %s\n' "$package" >&2
82+
exit 1
83+
}
84+
85+
installed_version="$(run_adb -s "$DEVICE_TARGET" shell dumpsys package "$package" 2>/dev/null \
86+
| sed -n 's/.*versionCode=\([0-9][0-9]*\).*/\1/p' | head -n 1)"
87+
installed_version="${installed_version:-0}"
88+
if [[ "$source" == managed ]]; then
89+
[[ "$expected_sha" == - ]] || {
90+
printf 'ERROR: managed package %s must use checksum "-"\n' "$package" >&2
91+
exit 1
92+
}
93+
if ((installed_version >= target_version && installed_version > 0)); then
94+
printf 'Managed/current: %s (%s)\n' "$package" "$installed_version"
95+
else
96+
printf 'MANAGED ACTION NEEDED: %s is missing or below %s (installed: %s)\n' \
97+
"$package" "$target_version" "$installed_version" >&2
98+
audit_failures=$((audit_failures + 1))
99+
fi
100+
continue
101+
fi
102+
[[ "$expected_sha" =~ ^[[:xdigit:]]{64}$ ]] || {
103+
printf 'ERROR: invalid SHA-256 for %s\n' "$package" >&2
104+
exit 1
105+
}
106+
if ((installed_version == target_version)); then
107+
printf 'Current: %s (%s)\n' "$package" "$target_version"
108+
continue
109+
fi
110+
if ((installed_version > target_version)); then
111+
printf 'ERROR: refusing downgrade of %s from %s to %s\n' \
112+
"$package" "$installed_version" "$target_version" >&2
113+
exit 1
114+
fi
115+
116+
apk_path="$tmp_dir/${package}.apk"
117+
curl --fail --location --silent --show-error "$source" --output "$apk_path"
118+
printf '%s %s\n' "${expected_sha,,}" "$apk_path" | sha256sum --check --status || {
119+
printf 'ERROR: checksum mismatch for %s\n' "$package" >&2
120+
exit 1
121+
}
122+
printf 'Updating: %s (%s -> %s)\n' "$package" "$installed_version" "$target_version"
123+
install_path="$apk_path"
124+
if [[ "$using_container" == true ]]; then
125+
install_path="/tmp/${package}-$$.apk"
126+
docker cp "$apk_path" "$ADB_CONTAINER:$install_path"
127+
container_apks+=("$install_path")
128+
fi
129+
run_adb -s "$DEVICE_TARGET" install -r -g "$install_path"
130+
updates=$((updates + 1))
131+
done <"$MANIFEST"
132+
133+
printf 'Sync complete: %s update(s) installed\n' "$updates"
134+
if ((audit_failures > 0)); then
135+
printf 'Managed package audit: %s action(s) needed\n' "$audit_failures" >&2
136+
exit 3
137+
fi

‎projectivy-deploy.sh‎

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#!/usr/bin/env bash
2+
# Install Projectivy Launcher on an Android TV device and select it as home.
3+
# Uses ADB_CONTAINER (default: androidtv-adb) when running, otherwise adb.
4+
5+
set -euo pipefail
6+
7+
readonly PROJECTIVY_PACKAGE="com.spocky.projengmenu"
8+
readonly PROJECTIVY_HOME="${PROJECTIVY_PACKAGE}/.ui.home.MainActivity"
9+
readonly ADB_CONTAINER="${ADB_CONTAINER:-androidtv-adb}"
10+
11+
usage() {
12+
cat >&2 <<EOF
13+
Usage: $0 <device-host> [<apk-path>|--launcher-only|--backup <file>|--restore <file>]
14+
15+
The device must expose ADB over TCP and trust the selected ADB client key.
16+
Set ADB_CONTAINER to use a running container other than androidtv-adb.
17+
EOF
18+
exit 2
19+
}
20+
21+
[[ $# -ge 1 ]] || usage
22+
readonly DEVICE_HOST="$1"
23+
readonly DEVICE_TARGET="${DEVICE_HOST}:5555"
24+
APK_PATH="${2:-$(dirname "$0")/projectivy.apk}"
25+
LAUNCHER_ONLY=false
26+
RESTORE_PATH=""
27+
BACKUP_PATH=""
28+
29+
case "${2:-}" in
30+
"") ;;
31+
--launcher-only) LAUNCHER_ONLY=true ;;
32+
--restore)
33+
LAUNCHER_ONLY=true
34+
RESTORE_PATH="${3:?--restore needs a path}"
35+
;;
36+
--backup)
37+
LAUNCHER_ONLY=true
38+
BACKUP_PATH="${3:?--backup needs a path}"
39+
;;
40+
--*)
41+
printf 'ERROR: unknown option: %s\n' "$2" >&2
42+
usage
43+
;;
44+
esac
45+
46+
declare -a adb_cmd
47+
using_container=false
48+
container_tmp=""
49+
if command -v docker >/dev/null 2>&1 \
50+
&& docker ps --format '{{.Names}}' 2>/dev/null | grep -Fxq "$ADB_CONTAINER"; then
51+
adb_cmd=(docker exec "$ADB_CONTAINER" adb)
52+
using_container=true
53+
container_tmp="/tmp/projectivy-deploy-$$"
54+
docker exec "$ADB_CONTAINER" mkdir -p "$container_tmp"
55+
printf 'Using ADB from container %s\n' "$ADB_CONTAINER"
56+
elif command -v adb >/dev/null 2>&1; then
57+
adb_cmd=(adb)
58+
printf 'Using host ADB\n'
59+
else
60+
printf 'ERROR: no running ADB container and no host adb executable\n' >&2
61+
exit 1
62+
fi
63+
64+
run_adb() {
65+
"${adb_cmd[@]}" "$@"
66+
}
67+
68+
cleanup() {
69+
run_adb disconnect "$DEVICE_TARGET" >/dev/null 2>&1 || true
70+
if [[ "$using_container" == true ]]; then
71+
docker exec "$ADB_CONTAINER" rm -rf "$container_tmp" >/dev/null 2>&1 || true
72+
fi
73+
}
74+
trap cleanup EXIT
75+
76+
printf 'Connecting to %s\n' "$DEVICE_TARGET"
77+
run_adb connect "$DEVICE_TARGET" >/dev/null
78+
run_adb -s "$DEVICE_TARGET" wait-for-device
79+
80+
device_name="$(run_adb -s "$DEVICE_TARGET" shell getprop ro.product.model 2>/dev/null | tr -d '\r' || true)"
81+
printf 'Connected: %s\n' "${device_name:-unknown}"
82+
83+
if [[ "$LAUNCHER_ONLY" == false ]]; then
84+
[[ -f "$APK_PATH" ]] || {
85+
printf 'ERROR: APK not found at %s; use --launcher-only after store installation\n' "$APK_PATH" >&2
86+
exit 1
87+
}
88+
install_path="$APK_PATH"
89+
if [[ "$using_container" == true ]]; then
90+
install_path="$container_tmp/projectivy.apk"
91+
docker cp "$APK_PATH" "$ADB_CONTAINER:$install_path"
92+
fi
93+
run_adb -s "$DEVICE_TARGET" install -r -g "$install_path"
94+
fi
95+
96+
if ! run_adb -s "$DEVICE_TARGET" shell pm list packages 2>/dev/null \
97+
| grep -Fq "package:${PROJECTIVY_PACKAGE}"; then
98+
printf 'ERROR: Projectivy is not installed; install it first or provide an APK\n' >&2
99+
exit 1
100+
fi
101+
102+
if [[ -n "$RESTORE_PATH" ]]; then
103+
[[ -f "$RESTORE_PATH" ]] || {
104+
printf 'ERROR: restore file not found: %s\n' "$RESTORE_PATH" >&2
105+
exit 1
106+
}
107+
printf 'Restoring Projectivy configuration; confirm the prompt on the TV\n'
108+
restore_path="$RESTORE_PATH"
109+
if [[ "$using_container" == true ]]; then
110+
restore_path="$container_tmp/projectivy.ab"
111+
docker cp "$RESTORE_PATH" "$ADB_CONTAINER:$restore_path"
112+
fi
113+
run_adb -s "$DEVICE_TARGET" restore "$restore_path"
114+
fi
115+
116+
if [[ -n "$BACKUP_PATH" ]]; then
117+
printf 'Backing up Projectivy configuration; confirm the prompt on the TV\n'
118+
backup_path="$BACKUP_PATH"
119+
if [[ "$using_container" == true ]]; then
120+
backup_path="$container_tmp/projectivy.ab"
121+
fi
122+
run_adb -s "$DEVICE_TARGET" backup -f "$backup_path" -noapk "$PROJECTIVY_PACKAGE"
123+
if [[ "$using_container" == true ]]; then
124+
docker cp "$ADB_CONTAINER:$backup_path" "$BACKUP_PATH"
125+
fi
126+
if [[ -r "$BACKUP_PATH" ]]; then
127+
printf 'Backup written: %s bytes\n' "$(wc -c <"$BACKUP_PATH" | tr -d '[:space:]')"
128+
else
129+
printf 'Backup written: missing\n'
130+
fi
131+
exit 0
132+
fi
133+
134+
printf 'Setting Projectivy as the default launcher\n'
135+
run_adb -s "$DEVICE_TARGET" shell cmd package set-home-activity "$PROJECTIVY_HOME"
136+
printf 'Done. Press Home on the TV to verify Projectivy launches.\n'

0 commit comments

Comments
 (0)