Client Build (Android phone + TV)
The client is a React Native app that uses react-native-tvos, so
one codebase targets phone/tablet and Android TV. It embeds Bare via
react-native-bare-kit and will not run in Expo Go. It needs a real
native build.
Device requirements — the APK installs from Android 7; P2P needs Android 10+
react-native-bare-kit's Bare runtime is built with native ELF TLS
(__tls_get_addr, added to Android's libc in Android 10 / API 29). So
the dynamic linker on Android 9 and older cannot load the P2P engine at
all. This is a real floor, not a conservative pin — the loader needs
__tls_get_addr@LIBC_Q, a hard GLOBAL import in libbare-kit.so
(verified). The app is no longer capped by it: the standard build is
a single minSdk 24 APK that loads the engine only where it can run
(next section).
Consequences for TV hardware:
- Fire TV. Fire OS 8 devices get full P2P — that's Android 11: Fire TV Stick 4K / 4K Max 2nd gen, 2023, Fire TV Cube 3rd gen, and Omni/4-Series TVs. Fire OS 7 devices (Android 9 — every 2018–2021 stick, including the 4K Max 1st gen) install and run the app, but the engine stays silent there (verified on a 4K Max 1st gen).
- Most Fire TV sticks expose a 32-bit userland —
armeabi-v7aonly; check withadb shell getprop ro.product.cpu.abilist. Build withgradlew :app:assembleRelease -PreactNativeArchitectures=armeabi-v7a(bare-kit ships armv7 prebuilds). Sideload over network adb (adb connect <tv-ip>:5555). The manifest already declaresLEANBACK_LAUNCHERand a TV banner, so the app appears in the TV launcher.
One APK from Android 7 up (engine gates itself at runtime)
The floor above is the engine's, not the APK's. The standard build is
a single APK with minSdk 24 that carries the engine and decides per
device. On Android 10+ the engine loads and runs in full. On Android 7–9
it is never loaded: the SDK is silently inactive
(AliranBackend.isSupported() returns false), and the app shows a plain
"engine unavailable" notice. There is no P2P playback path below Android
10, period — the catalog itself only exists over the swarm.
What makes this possible is
client/patches/react-native-bare-kit+0.13.3.patch, applied
automatically by the patch-package postinstall. It turns the module's
link-time dependency on libbare-kit.so into a lazy dlopen/dlsym,
resolved only on API 29+, so React init no longer pulls the engine
library on old devices. Just build:
./gradlew :app:assembleRelease
Sanity-check the output: aapt dump badging … | grep sdkVer says 24,
unzip -l app-release.apk | grep libbare-kit shows the engine aboard, and
readelf on libappmodules.so shows no libbare-kit.so NEEDED
entry. Android 7 is React Native's own floor, not ours. RN 0.76+
prebuilds are built for API 24, and the build rejects a lower minSdk. So
Android 6 devices can't run a current-RN app at all.
Optional: ALIRAN_LEGACY=1 ./gradlew :app:assembleRelease builds an
engine-less lean APK — about 55 MB/ABI smaller, with
react-native-bare-kit excluded from autolinking via
client/react-native.config.js — for old-device-only fleets. Same minSdk
24, same silent-SDK behavior everywhere. Details and the reasoning are in
the
Android build KB.
Prerequisites (the main hurdle)
Install in order:
- Node LTS, Git
- JDK 17 (Temurin)
- Android Studio → SDK Platform 34, Platform-Tools, NDK, CMake
- Set
ANDROID_HOME; addplatform-toolstoPATH - An emulator (phone AVD and an Android TV AVD) or physical devices
- Verify:
npx react-native doctor
Windows is fully supported for Android builds (no Mac needed).
Install dependencies
The native android/ project is checked in: react-native-tvos 0.83, New
Architecture.
cd client
npm install # app deps (also links backend/ via @aliran/client-backend)
cd backend && npm install && cd .. # backend worklet deps (@aliran/core + hyper stack)
Bundle the Bare backend
npm run bundle-backend # bare-pack --preset android → backend/app.bundle.js (base64)
Notes:
- backend/imports.json remaps node:crypto to @aliran/bare-node-crypto,
a small sodium-backed WebCrypto shim, because the bare-kit worklet
runtime has no node-style builtins. backend/globals.mjs polyfills
TextEncoder/TextDecoder/globalThis.crypto.
- Native addons — sodium-native ×2 majors, udx-native,
quickbit/rabin/simdle/crc, fs-native-extensions — ship as npm
prebuilds. react-native-bare-kit's gradle link task (bare-link)
packages them per-ABI automatically by walking the app's dependency
graph. That is why client/package.json depends on
@aliran/client-backend.
The on-device store is a disposable cache
The worklet keeps its Corestore at
/data/data/<pkg>/files/aliran-store. It holds only replicas — panel
DB, assets drive, feed drives. Every byte re-replicates from peers, and
nothing user-owned lives there. If the app process dies mid-write (crash,
task kill), hypercore can refuse to reopen a core (OPLOG_CORRUPT and
friends). The backend detects this, wipes the store automatically, and
retries once (client/backend/recover.mjs), so playback recovers
without user action. Deleting the directory by hand, or running
adb shell pm clear <pkg>, is always safe — it only costs a
re-replication. Verified by npm run test:corrupt (repo root).
App structure (since the GUI redesign)
Splash (boot + auto-auth: "Authorizing device…")
├─ Connect public (keyless) builds only: first run, or after "Change service…" —
│ the viewer enters their operator's panel key + credentials
├─ Login only when there are no saved credentials, or they stopped working
└─ Menu icon-bar hub over the featured stream's wallpaper (sections are
│ descriptor-driven; Exit is TV-only by default)
├─ Live ONE fullscreen video surface; browsing happens in overlay panels
│ (category rail + channel list, and a channel-detail panel) — the
│ video keeps playing while you browse; selecting a row switches
│ the stream in place; D-pad up/down zaps when fullscreen
├─ Favorites device-local ★ channels
├─ Search client-side filter (title/description/category)
└─ Settings account / service / diagnostics / sign out
- Auto-login ("remember me"). After a successful sign-in, the app
saves the credentials to the app-private files dir
(
aliran-prefs.json). This sits beside — not inside — the disposable store, so corruption recovery never wipes them. The credentials are plaintext-at-rest inside the Android app sandbox — the normal tradeoff for this app class. Sign-out deletes it. Favorites live in the same file. - White-label contract. Screens and components contain no brand
names, colors, or section lists. Everything flows from
config/service.json(the service descriptor) throughtheme.ts makeTheme(). Swap the descriptor, and you ship a different brand. Per-brand APK packaging — own applicationId, launcher icon, splash logo, wallpaper, theme; brands co-install — istools/brand.mjs(see White-label branding). Channel numbers are derived from the panel's curation (order, then title), and are never stored. No EPG data exists yet, so the channel-detail panel shows an honest "No program information" placeholder instead of a fake guide.
Configure the panel key — two flavors, one codebase
The build's config/service.json decides the flavor. This mirrors the
desktop player:
- Operator (baked) flavor. Copy
config/service.example.jsontoconfig/service.jsonand set yourpanelPubKeyand branding. The key ships in the APK, and the app boots straight onto it. It is not changeable at runtime — Settings shows it read-only.tools/brand.mjsswaps this file per brand. - Public (keyless) flavor. Copy the committed
config/service.public.jsontoconfig/service.jsoninstead — itspanelPubKeyis the empty string, the deliberate keyless marker. First run shows a Connect screen asking which service to use, plus a username and a password. These persist on the device (aliran-prefs.json, only after a successful sign-in), and Settings gains "Change service…" to forget them and reconnect. One generic APK connects to any operator's panel, phone and TV alike.
Precedence is baked → persisted runtime service → Connect screen. A baked key always wins, and it ignores any persisted one.
The Connect screen — the pairing code, or the key
The Connect screen accepts the service in two forms. Both give the same result.
- The service pairing code (the default). 12 characters in three
groups of four, for example
A3K7-9QF2-M4XR. The groups advance themselves as the viewer types. This is the form to give a viewer: the panel key is 64 characters, which is slow on a phone keyboard and very slow on a TV remote. - The panel public key. The full 64 characters. One press on "Enter the 64-character panel key instead" shows this field.
The operator finds the code in the panel dashboard, on the Overview tab. See the operator guide.
The app resolves a pairing code over the peer-to-peer network, then calculates the code again from the panel key it receives. If the two codes do not agree, the app refuses that service. Thus a different service cannot take the place of the operator's own. See the security model.
The code holds no password. The viewer signs in with their own account after the app finds the service.
The vod block — external-provider dev override only
If the operator's panel has an
external VOD provider
enabled, the app shows a Movies & Series section. Nothing about that
is configured in the descriptor. The provider's coordinates (apiBase /
service / sources / params) always come from the panel on the login
payload. In production, the app authenticates to the provider with the
viewer's own app account: the username as username, and the app
password as token. The sources map is per-kind (movies / series) —
series browsing appears only when the operator has set a series source
(see
the white-label page).
The section's watchlist and watch history are device-local — they
live in the same aliran-prefs.json the app already keeps (see
the privacy note).
Clearing app data resets them along with everything else.
The only thing a descriptor may carry is a dev-time credential override for testing against a provider account that is not a viewer account:
"vod": { "dev": { "username": "…", "token": "…" } }
Rules that keep this safe:
- Put it only in your local, gitignored
config/service.json. Never ship a build whose descriptor contains it. The descriptor is baked into the APK's JS bundle, so anyone can lift the credential out of the file. - The example file's placeholder words (
YOUR_USERNAME/YOUR_TOKEN) are recognized and ignored. A copied-but-unfilled block falls through to the normal viewer pass-off instead of sending the literals to the provider. - The app refuses cleartext everywhere here. A non-https
apiBaseis never dialed, and the provider's playable URLs — which embed the token via a{token}placeholder — are only completed over https.
Gradle gotcha: the release JS-bundling task does not track
client/config/*.jsonas an input. After editingservice.json, deleteandroid/app/build/generated/assets/react(or run the bundle task with--rerun-tasks) so the descriptor change actually lands in the APK.
Build & install
cd android && ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
Test on a phone and an Android TV target — drive the TV build with a remote or D-pad.
Manifest notes (dual phone + TV)
Declare both LAUNCHER and LEANBACK_LAUNCHER intents. Set
android.software.leanback to not required, and set
android.hardware.touchscreen to required=false. Allow 127.0.0.1
cleartext in the network security config, and add the INTERNET
permission.