From a5dc8646fa814c3382f323efb58b25f5551ce035 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 5 Nov 2026 00:23:31 +0000 Subject: [PATCH 1/2] feat: PairUX on Fire TV (com.pairux.tv) A leanback WebView shell for pairux.com, ported from the pit.world Fire TV app, so a room can be watched on a television. - package com.pairux.tv 1.0.0, minSdk 22 (Fire OS 5), target 34 - assets/tv.js gives the page a remote: the D-pad moves a focus ring, OK activates, Back walks history; a native EditText dialog raises the Fire TV keyboard - The Menu key asks for a room: a full link loads as given (an E2EE room carries its key in the fragment), anything else is taken for a code and joins /r/ - WebRTC: the page's getUserMedia reaches Android through onPermissionRequest, which grants only what is already held and otherwise asks the viewer first. Microphone and camera are declared uses-feature required="false" - watching needs neither, and a Fire TV has no camera - art rendered from store/src/*.svg in the PairUX palette (#0f172a/#e2e8f0/#3b82f6) - release key in vault profullstack/pairux-firetv--signing (cert SHA-256 e7514f63...a397) Co-Authored-By: Claude Opus 5 (1M context) --- firetv/.gitignore | 5 + firetv/README.md | 51 ++ firetv/app/build.gradle | 46 ++ firetv/app/src/main/AndroidManifest.xml | 48 ++ firetv/app/src/main/assets/offline.html | 24 + firetv/app/src/main/assets/tv.js | 159 ++++++ .../main/java/com/pairux/tv/MainActivity.java | 476 ++++++++++++++++++ .../src/main/res/drawable-xhdpi/banner.png | Bin 0 -> 7469 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 2446 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 3759 bytes firetv/app/src/main/res/values/strings.xml | 6 + firetv/app/src/main/res/values/styles.xml | 20 + firetv/build.gradle | 1 + firetv/build.sh | 31 ++ firetv/emulator-test.sh | 82 +++ firetv/gradle.properties | 2 + firetv/new-key.sh | 15 + firetv/settings.gradle | 9 + firetv/store/listing.md | 52 ++ firetv/store/render.sh | 20 + firetv/store/src/background.svg | 17 + firetv/store/src/banner.svg | 14 + firetv/store/src/icon.svg | 10 + 23 files changed, 1088 insertions(+) create mode 100644 firetv/.gitignore create mode 100644 firetv/README.md create mode 100644 firetv/app/build.gradle create mode 100644 firetv/app/src/main/AndroidManifest.xml create mode 100644 firetv/app/src/main/assets/offline.html create mode 100644 firetv/app/src/main/assets/tv.js create mode 100644 firetv/app/src/main/java/com/pairux/tv/MainActivity.java create mode 100644 firetv/app/src/main/res/drawable-xhdpi/banner.png create mode 100644 firetv/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 firetv/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 firetv/app/src/main/res/values/strings.xml create mode 100644 firetv/app/src/main/res/values/styles.xml create mode 100644 firetv/build.gradle create mode 100644 firetv/build.sh create mode 100644 firetv/emulator-test.sh create mode 100644 firetv/gradle.properties create mode 100644 firetv/new-key.sh create mode 100644 firetv/settings.gradle create mode 100644 firetv/store/listing.md create mode 100644 firetv/store/render.sh create mode 100644 firetv/store/src/background.svg create mode 100644 firetv/store/src/banner.svg create mode 100644 firetv/store/src/icon.svg diff --git a/firetv/.gitignore b/firetv/.gitignore new file mode 100644 index 00000000..d544a517 --- /dev/null +++ b/firetv/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +app/build/ +build/ +dist/ +local.properties diff --git a/firetv/README.md b/firetv/README.md new file mode 100644 index 00000000..5fd3a368 --- /dev/null +++ b/firetv/README.md @@ -0,0 +1,51 @@ +# pairux.com for Fire TV + +A small native Android app (`com.pairux.tv`) that puts https://pairux.com on a Fire TV +and makes it work with a remote. No dependencies beyond the Android SDK. + +- **WebView** on pairux.com. Results open in the app; `mailto:`/`intent:` links go to the system. +- **Remote navigation** (`app/src/main/assets/tv.js`): Chromium's WebView scrolls on arrow + keys instead of moving between links, so every D-pad press runs `pitNav(dir)` in the page: + spatial focus to the nearest link/button, scrolling across long gaps, and a large focus + ring. The ring is set through `element.style` because pairux.com's CSP (`style-src 'self'`) + blocks an injected ` + + +
+

pairux.com is out of reach

+

Check this TV's network connection, then press OK to try again.

+ Try again +
+ + diff --git a/firetv/app/src/main/assets/tv.js b/firetv/app/src/main/assets/tv.js new file mode 100644 index 00000000..80882379 --- /dev/null +++ b/firetv/app/src/main/assets/tv.js @@ -0,0 +1,159 @@ +/* pairux.com for Fire TV: remote-control navigation for any page in the WebView. + * + * Chromium's WebView scrolls on arrow keys instead of moving between links, so + * the activity hands every D-pad press to pitNav() and OK to pitEnter(). Both + * return a short string the activity acts on. Written in ES5 on purpose: Fire + * OS 5 devices still ship WebView 70-something. + */ +(function () { + if (window.__pitTV) return; + window.__pitTV = true; + + var SEL = 'a[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),' + + 'select:not([disabled]),textarea:not([disabled]),summary,[tabindex]:not([tabindex="-1"])'; + var TEXT = /^(search|text|email|url|tel|number|password|)$/; + var field = null; // the text field OK was pressed on, while the keyboard is up + + // The focus ring is set through CSSOM on the element itself: pairux.com (and + // many sites) ship style-src 'self', which blocks an injected + + + diff --git a/firetv/build.gradle b/firetv/build.gradle new file mode 100644 index 00000000..bb97a9ac --- /dev/null +++ b/firetv/build.gradle @@ -0,0 +1 @@ +plugins { id 'com.android.application' version '8.5.2' apply false } diff --git a/firetv/build.sh b/firetv/build.sh new file mode 100644 index 00000000..f3ad9ebc --- /dev/null +++ b/firetv/build.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Builds the Fire TV APK. Runs inside gradle:8.7-jdk17; see README.md for the docker line. +# /sdk Android SDK cache (installed on first run) +# /keys optional: release.jks + release.env (PX_TV_STORE_PASSWORD=...) to sign +set -eu +SDK=/sdk +TOOLS=commandlinetools-linux-11076708_latest.zip +if [ ! -x "$SDK/cmdline-tools/latest/bin/sdkmanager" ]; then + mkdir -p "$SDK/cmdline-tools" + curl -fsSL "https://dl.google.com/android/repository/$TOOLS" -o /tmp/tools.zip + unzip -q /tmp/tools.zip -d "$SDK/cmdline-tools" + mv "$SDK/cmdline-tools/cmdline-tools" "$SDK/cmdline-tools/latest" +fi +yes | "$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" --licenses >/dev/null +"$SDK/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$SDK" "platforms;android-34" "build-tools;34.0.0" >/dev/null +export ANDROID_HOME="$SDK" + +if [ -f /keys/release.jks ]; then + set -a; . /keys/release.env; set +a + export PX_TV_KEYSTORE=/keys/release.jks +fi + +gradle --no-daemon -q lint assembleRelease +OUT=app/build/outputs/apk/release +APK=$(ls "$OUT"/*.apk | head -1) +"$SDK/build-tools/34.0.0/apksigner" verify --print-certs "$APK" | head -3 || true +"$SDK/build-tools/34.0.0/aapt2" dump badging "$APK" | grep -E "^(package|sdkVersion|targetSdkVersion|leanback|application-label|uses-feature)" || true +mkdir -p dist +cp "$APK" dist/pairux-firetv.apk +[ -n "${OWNER:-}" ] && chown -R "$OWNER" dist app/build .gradle 2>/dev/null +ls -l dist diff --git a/firetv/emulator-test.sh b/firetv/emulator-test.sh new file mode 100644 index 00000000..cbb40a25 --- /dev/null +++ b/firetv/emulator-test.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Boots an Android TV emulator (API 30, the base of Fire OS 8), installs the APK, +# drives it with remote-control keys and saves 1920x1080 screenshots to store/shots. +# Runs inside gradle:8.7-jdk17 with --device /dev/kvm and the SDK cache at /sdk: +# docker run --rm --device /dev/kvm -v ~/scratch/android-sdk:/sdk -v "$PWD/firetv:/w" -w /w \ +# gradle:8.7-jdk17 bash emulator-test.sh +set -euo pipefail +SDK=/sdk +export ANDROID_HOME=$SDK ANDROID_SDK_ROOT=$SDK ANDROID_AVD_HOME=/root/.android/avd +IMG="system-images;android-30;android-tv;x86" + +apt-get update -qq >/dev/null +apt-get install -y -qq libpulse0 libnss3 libxcomposite1 libxcursor1 libxi6 libxtst6 \ + libasound2 libgl1 libxkbfile1 libxdamage1 libxrandr2 libdrm2 libgbm1 >/dev/null 2>&1 + +SM="$SDK/cmdline-tools/latest/bin/sdkmanager --sdk_root=$SDK" +(yes || true) | $SM --licenses >/dev/null || true # yes dies of SIGPIPE: not a failure +$SM "emulator" "platform-tools" "$IMG" >/dev/null + +echo no | "$SDK/cmdline-tools/latest/bin/avdmanager" create avd -n tv -k "$IMG" -d tv_1080p --force >/dev/null +ADB="$SDK/platform-tools/adb" +"$SDK/emulator/emulator" -avd tv -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect \ + -no-snapshot -memory 2048 >/tmp/emu.log 2>&1 & + +"$ADB" wait-for-device +for i in $(seq 1 120); do + [ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] && break + sleep 2 +done +echo "booted" +"$ADB" shell wm size + +# The package manager comes up a little after boot_completed; retry until it takes. +for i in $(seq 1 20); do "$ADB" install -r dist/pairux-firetv.apk && break; sleep 5; done +mkdir -p store/shots +shot() { "$ADB" exec-out screencap -p > "store/shots/$1.png"; echo "shot $1"; } +key() { "$ADB" shell input keyevent "$@"; sleep 1.2; } +focus() { "$ADB" shell dumpsys window 2>/dev/null | grep -m1 mCurrentFocus || true; } + +# 1. Leanback launch, the way the Fire TV home screen opens it. +"$ADB" shell monkey -p com.pairux.tv -c android.intent.category.LEANBACK_LAUNCHER 1 >/dev/null +sleep 12 +focus +shot 01-home + +# 2. The home page autofocuses the search box: OK raises the Fire TV keyboard. +# (Arrows moving the focus ring are checked on the results page below.) +key KEYCODE_DPAD_CENTER +sleep 2 +focus +shot 03-keyboard +"$ADB" shell input text "demo" +key KEYCODE_ENTER +sleep 10 +shot 04-results +key KEYCODE_DPAD_DOWN KEYCODE_DPAD_DOWN KEYCODE_DPAD_DOWN KEYCODE_DPAD_DOWN +shot 05-results-nav +key KEYCODE_DPAD_RIGHT +shot 05b-results-right + +# 3. Back returns to the home page, Back again leaves the app. +key KEYCODE_BACK +sleep 4 +shot 06-back +key KEYCODE_BACK +sleep 2 +focus + +# 4. Menu key opens the standalone search box. +"$ADB" shell monkey -p com.pairux.tv -c android.intent.category.LEANBACK_LAUNCHER 1 >/dev/null +sleep 8 +key KEYCODE_MENU +sleep 2 +shot 07-menu-search +"$ADB" shell input text "demo" +key KEYCODE_ENTER +sleep 10 +shot 08-code-entered + +"$ADB" logcat -d | grep -E "AndroidRuntime|FATAL|com.pairux.tv|chromium.*Uncaught" | tail -20 || true +[ -n "${OWNER:-}" ] && chown -R "$OWNER" store/shots +ls -l store/shots diff --git a/firetv/gradle.properties b/firetv/gradle.properties new file mode 100644 index 00000000..ac73bc4a --- /dev/null +++ b/firetv/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g +android.nonTransitiveRClass=true diff --git a/firetv/new-key.sh b/firetv/new-key.sh new file mode 100644 index 00000000..a242f87c --- /dev/null +++ b/firetv/new-key.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Mints the release keystore once. Runs inside eclipse-temurin:17-jdk with the +# output dir mounted at /keys. Store both files in the vault (pairux-firetv--signing as +# PX_TV_KEYSTORE_B64 + PX_TV_STORE_PASSWORD), never in the repo. Losing this key +# means Fire TV users cannot update in place, so do not run it twice. +set -eu +[ -f /keys/release.jks ] && { echo "refusing: /keys/release.jks exists"; exit 1; } +umask 077 +PW=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n') +keytool -genkeypair -keystore /keys/release.jks -storetype PKCS12 -alias pairux \ + -keyalg RSA -keysize 4096 -validity 36500 -storepass "$PW" \ + -dname "CN=PairUX (pairux.com), O=Profullstack, C=US" >/dev/null 2>&1 +printf 'PX_TV_STORE_PASSWORD=%s\nPX_TV_KEY_ALIAS=pairux\n' "$PW" > /keys/release.env +[ -n "${OWNER:-}" ] && chown "$OWNER" /keys/release.jks /keys/release.env +keytool -list -keystore /keys/release.jks -storepass "$PW" | tail -2 diff --git a/firetv/settings.gradle b/firetv/settings.gradle new file mode 100644 index 00000000..0b54ad92 --- /dev/null +++ b/firetv/settings.gradle @@ -0,0 +1,9 @@ +pluginManagement { + repositories { google(); mavenCentral(); gradlePluginPortal() } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { google(); mavenCentral() } +} +rootProject.name = "pairux-tv" +include ":app" diff --git a/firetv/store/listing.md b/firetv/store/listing.md new file mode 100644 index 00000000..6360c4da --- /dev/null +++ b/firetv/store/listing.md @@ -0,0 +1,52 @@ +# Store listing: PairUX (Fire TV) + +- **Title:** pairux +- **Package:** com.pairux.tv +- **Category:** Productivity +- **Price:** Free. No in-app purchases in the Fire TV app. No ads. +- **What it is on a TV:** a viewer. Open a PairUX room on the big screen and watch the + screen someone else is sharing. Sharing your own screen and taking remote control are + what the desktop app is for; a Fire TV has no screen to share and no pointer to lend. +- **Audience:** 13+ +- **Collects data:** an account (email, name) for people who sign in; a room can also be + joined anonymously by link. The microphone is used only if the viewer joins a call and + grants it, and a Fire TV has no camera. +- **Privacy policy URL:** https://pairux.com/privacy +- **Support:** anthony@profullstack.com, https://github.com/profullstack/pairux.com/issues + +## Short description + +Watch a PairUX screen share on your TV. Open a room by code or link, see what the other person is sharing on the big screen, and drive the whole thing with the remote. + +## Long description + +PairUX is collaborative screen sharing with simultaneous remote mouse and keyboard control, like Screenhero but open source. This is PairUX on a television: the viewer's half. + +- Join a room by code, or follow a room link, and watch the shared screen full size +- Made for the remote: the D-pad moves a clear focus ring, OK activates, Back walks back, and the Menu key asks for a room code from anywhere +- Rooms can be anonymous and end to end encrypted; the key lives in the link, never on a server +- Sign in to reach your own channels and scheduled sessions +- Free and open source (MIT): github.com/profullstack/pairux.com + +Sharing your own screen and remote control need a computer: install the PairUX desktop app from pairux.com. The Fire TV app is for watching. + +## Feature bullets + +Watch a PairUX screen share on the big screen +Join a room by code or by link +Anonymous end-to-end encrypted rooms +Made for the remote: focus ring, D-pad, Menu for a room code +Free, open source, no ads + +## Keywords + +screen sharing, pair programming, remote control, screenhero, collaboration, pairux + +## Art + +| Slot | File | +| --- | --- | +| Icon 512x512 / 114x114 | out/icon-512.png, out/icon-114.png | +| Fire TV icon 1280x720 | out/firetv-icon-1280x720.png | +| Fire TV background 1920x1080 | out/firetv-background-1920x1080.png | +| Screenshots 1920x1080 | shots/*.png | diff --git a/firetv/store/render.sh b/firetv/store/render.sh new file mode 100644 index 00000000..7d70298d --- /dev/null +++ b/firetv/store/render.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Renders the launcher icons, TV banner and Amazon Appstore art from store/src/*.svg. +# Runs inside alpine (see firetv/README.md): docker run --rm -v "$PWD/firetv:/w" -w /w alpine:3.20 sh store/render.sh +set -eu +apk add --no-cache rsvg-convert font-dejavu >/dev/null +R=app/src/main/res +O=store/out +mkdir -p "$O" "$R/mipmap-xhdpi" "$R/mipmap-xxhdpi" "$R/drawable-xhdpi" + +rsvg-convert -w 96 -h 96 store/src/icon.svg -o "$R/mipmap-xhdpi/ic_launcher.png" +rsvg-convert -w 144 -h 144 store/src/icon.svg -o "$R/mipmap-xxhdpi/ic_launcher.png" +rsvg-convert -w 320 -h 180 store/src/banner.svg -o "$R/drawable-xhdpi/banner.png" + +rsvg-convert -w 512 -h 512 store/src/icon.svg -o "$O/icon-512.png" +rsvg-convert -w 114 -h 114 store/src/icon.svg -o "$O/icon-114.png" +rsvg-convert -w 1280 -h 720 store/src/banner.svg -o "$O/firetv-icon-1280x720.png" +rsvg-convert -w 1920 -h 1080 store/src/background.svg -o "$O/firetv-background-1920x1080.png" +# The container writes as root; hand the files back to whoever ran it. +[ -n "${OWNER:-}" ] && chown -R "$OWNER" "$R" "$O" +ls -l "$O" diff --git a/firetv/store/src/background.svg b/firetv/store/src/background.svg new file mode 100644 index 00000000..18b32002 --- /dev/null +++ b/firetv/store/src/background.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/firetv/store/src/banner.svg b/firetv/store/src/banner.svg new file mode 100644 index 00000000..6b778d77 --- /dev/null +++ b/firetv/store/src/banner.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + PairUX + watch a shared screen on your TV + diff --git a/firetv/store/src/icon.svg b/firetv/store/src/icon.svg new file mode 100644 index 00000000..9e013194 --- /dev/null +++ b/firetv/store/src/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From 02950e677395fc947859ddd935e07d35da501151 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 5 Nov 2026 00:45:54 +0000 Subject: [PATCH 2/2] fix(firetv): use PairUX's own logo, and keep the icons transparent The icons were a hand-drawn approximation of the mark on a solid square. store/src/icon.svg is now apps/web/public/favicon.svg verbatim and the banner uses logo.light.svg, so the launcher icon, the Appstore icons and the TV banner are all the real artwork. The 512 and 114 icons keep their alpha; only the Fire TV art is flattened, because those slots refuse transparency. Also formats tv.js and listing.md with prettier: format:check is repo-wide. Co-Authored-By: Claude Opus 5 (1M context) --- firetv/app/src/main/assets/tv.js | 96 ++++++++++++++---- .../src/main/res/drawable-xhdpi/banner.png | Bin 7469 -> 8375 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 2446 -> 3293 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 3759 -> 4998 bytes firetv/store/listing.md | 10 +- firetv/store/render.sh | 31 ++++-- firetv/store/shots/01-home.png | Bin 0 -> 38170 bytes firetv/store/shots/03-keyboard.png | Bin 0 -> 38170 bytes firetv/store/shots/04-results.png | Bin 0 -> 38170 bytes firetv/store/shots/05-results-nav.png | Bin 0 -> 51420 bytes firetv/store/shots/05b-results-right.png | Bin 0 -> 38580 bytes firetv/store/shots/06-back.png | Bin 0 -> 689880 bytes firetv/store/shots/07-menu-search.png | Bin 0 -> 91157 bytes firetv/store/shots/08-code-entered.png | Bin 0 -> 80615 bytes firetv/store/src/background.svg | 18 +--- firetv/store/src/banner.svg | 17 +--- firetv/store/src/icon.svg | 11 +- firetv/store/src/logo-light.svg | 1 + 18 files changed, 110 insertions(+), 74 deletions(-) create mode 100644 firetv/store/shots/01-home.png create mode 100644 firetv/store/shots/03-keyboard.png create mode 100644 firetv/store/shots/04-results.png create mode 100644 firetv/store/shots/05-results-nav.png create mode 100644 firetv/store/shots/05b-results-right.png create mode 100644 firetv/store/shots/06-back.png create mode 100644 firetv/store/shots/07-menu-search.png create mode 100644 firetv/store/shots/08-code-entered.png create mode 100644 firetv/store/src/logo-light.svg diff --git a/firetv/app/src/main/assets/tv.js b/firetv/app/src/main/assets/tv.js index 80882379..6177b661 100644 --- a/firetv/app/src/main/assets/tv.js +++ b/firetv/app/src/main/assets/tv.js @@ -9,7 +9,8 @@ if (window.__pitTV) return; window.__pitTV = true; - var SEL = 'a[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),' + + var SEL = + 'a[href],button:not([disabled]),input:not([type=hidden]):not([disabled]),' + 'select:not([disabled]),textarea:not([disabled]),summary,[tabindex]:not([tabindex="-1"])'; var TEXT = /^(search|text|email|url|tel|number|password|)$/; var field = null; // the text field OK was pressed on, while the keyboard is up @@ -17,8 +18,11 @@ // The focus ring is set through CSSOM on the element itself: pairux.com (and // many sites) ship style-src 'self', which blocks an injected - - - - - - - - - + + \ No newline at end of file diff --git a/firetv/store/src/banner.svg b/firetv/store/src/banner.svg index 6b778d77..da3af864 100644 --- a/firetv/store/src/banner.svg +++ b/firetv/store/src/banner.svg @@ -1,14 +1,7 @@ + - - - - - - - - - - PairUX - watch a shared screen on your TV - + + PairUX + watch a shared screen on your TV + \ No newline at end of file diff --git a/firetv/store/src/icon.svg b/firetv/store/src/icon.svg index 9e013194..d5e8ef28 100644 --- a/firetv/store/src/icon.svg +++ b/firetv/store/src/icon.svg @@ -1,10 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/firetv/store/src/logo-light.svg b/firetv/store/src/logo-light.svg new file mode 100644 index 00000000..cbad525d --- /dev/null +++ b/firetv/store/src/logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file