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 userlandarmeabi-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.
  • 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 app refuses cleartext everywhere here. 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.

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. Allow 127.0.0.1 cleartext in the network security config, and add the INTERNET permission.