Skip to content

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-v7a only; check with adb shell getprop ro.product.cpu.abilist. Build with gradlew :app:assembleRelease -PreactNativeArchitectures=armeabi-v7a (bare-kit ships armv7 prebuilds). Sideload over network adb (adb connect <tv-ip>:5555). The manifest already declares LEANBACK_LAUNCHER and 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:

  1. Node LTS, Git
  2. JDK 17 (Temurin)
  3. Android Studio → SDK Platform 34, Platform-Tools, NDK, CMake
  4. Set ANDROID_HOME; add platform-tools to PATH
  5. An emulator (phone AVD and an Android TV AVD) or physical devices
  6. 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.
  • A television stays signed in. A set signed in from a phone ("send to TV") has no password to save, so it keeps the account key material instead — sealed under a key held in the Android Keystore, in the same prefs file, and usable only by this app on this device. Without it, each time Android reclaimed the app process the viewer had to fetch their phone again, which left the feature worse off than the password path it replaced. TV builds only; a phone keeps nothing, because a phone still has a keyboard. Sign-out erases the record and destroys the Keystore key, and so does changing the operator. The set falls back to the sign-in screen — never to a half-signed-in state — and it erases what it holds only on proof: a key store that says the bytes will never open, a record that fails its seal, an operator this device has left, or the panel's verdict on the account itself (disabled, or gone from its database). A key store that did not answer, a swarm still dialling, an account record that has not replicated yet, or an operator whose device slots are full all keep the material and try again on the next start, because erasing costs the viewer a walk to another room for a phone. Those retries are budgeted in panel logins, not in seconds, so a set can never lock out the account it is restoring — nor the password screen a viewer is standing at. See Security model — Account keys at rest, which is explicit about what the Keystore does and does not protect against.
  • White-label contract. Screens and components contain no brand names, colors, or section lists. Everything flows from config/service.json (the service descriptor) through theme.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 — is tools/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.json to config/service.json and set your panelPubKey and 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.mjs swaps this file per brand.
  • Public (keyless) flavor. Copy the committed config/service.public.json to config/service.json instead — its panelPubKey is 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 VOD client enforces https on itself, whatever the platform permits. A non-https apiBase is never dialed, and the provider's playable URLs — which embed the token via a {token} placeholder — are only completed over https. This is a rule in the app's own code, not a consequence of the network security config: the shipped config permits cleartext broadly (see the manifest notes below), so nothing else stops a token going out in clear.

Gradle gotcha: the release JS-bundling task does not track client/config/*.json as an input. After editing service.json, delete android/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. Add the INTERNET permission, and permit cleartext in the network security config.

How much cleartext, and why. The engine itself needs only 127.0.0.1, and loopback-only is the right setting for a build that carries P2P channels alone. The shipped app goes further: its release network_security_config.xml permits cleartext to every host, so that redirect and event channels a provider serves over plain http:// can play. That is a deliberate operator decision with a named cost — cleartext stream traffic is unencrypted and can be read or changed by anyone on the network path — and the panel's own source-scoped allowCleartext exemption is the half that lets those URLs into the catalog at all. Pick the posture your lineup needs; the file carries the reasoning either way. Note that a network security config governs the app's outbound requests only, so it neither permits nor blocks the inbound LAN server a cast session stands up.