# Android White-Screen RCA — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Identify and fix the cause of the Android white-screen-after-install on the mobile build, and ship defense-in-depth so the next boot-time failure renders a readable error screen instead of a blank window. **Architecture:** Wire EXPO_PUBLIC_* into EAS builds via EAS Secrets (the leading hypothesis). Make `env.ts` lazy so missing variables throw inside React. Add an `` boundary inside `_layout.tsx` that initialises the crypto backend in a `useEffect`, renders a splash while loading, and routes any error to a `` view. Install a global JS error handler as the last-resort net. Then run the diagnostic playbook against a fresh APK to confirm which hypothesis actually fired. **Tech Stack:** Expo SDK 52, React Native 0.76, TypeScript, EAS Build, expo-secure-store, react-native-libsodium. **Spec:** `docs/superpowers/specs/2026-05-16-android-whitescreen-rca-design.md` **Decisions inherited from spec review gate:** - EAS Secrets (not `eas.json env`) for `EXPO_PUBLIC_*` values. - `newArchEnabled: false` is acceptable as a temporary rollback if H2 confirms. --- ## File Overview **New files (mobile app):** - `apps/mobile/components/BootError.tsx` — full-screen error fallback with env diagnostic - `apps/mobile/components/BootSplash.tsx` — minimal splash shown while the crypto backend warms up - `apps/mobile/components/AppBootstrap.tsx` — boundary that initialises the crypto backend in a `useEffect` and routes errors to `BootError` - `apps/mobile/lib/env.test.ts` — lazy proxy + missing-var coverage - `apps/mobile/scripts/check-env.mjs` — optional lint comparing `.env.example` against `.env.local` **Modified files (mobile app):** - `apps/mobile/lib/env.ts` — convert to lazy proxy - `apps/mobile/app/_layout.tsx` — remove the module-eval crypto init; mount `` at the top of the tree - `apps/mobile/README.md` — add an "EAS env" section - `apps/mobile/package.json` — add `check:env` script **No files deleted.** No native code changes. --- ## Phase 0 — Wire env into EAS builds (the leading hypothesis fix) ### Task 1: Create EAS Secrets and document the contract **Files:** - Modify: `apps/mobile/README.md` - [ ] **Step 1: Confirm EAS CLI is installed and authenticated** Run: ```bash cd apps/mobile npx eas-cli --version npx eas-cli whoami ``` Expected: a version string ≥ 13.0.0, and the `whoami` output shows the `bygalax` owner (matches `app.json` `expo.owner`). If `whoami` errors, run `npx eas-cli login` interactively in a terminal (this plan cannot be executed in a sandboxed shell). - [ ] **Step 2: Create the three project-scoped secrets** Run, substituting the real Supabase project URL and `sb_publishable_…` anon key (look them up in `apps/mobile/.env.local`): ```bash npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '' npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '' npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback' ``` Expected: each command prints `✔ Created a new secret EXPO_PUBLIC_…`. List to confirm: ```bash npx eas-cli secret:list ``` Expected: all three names present, type `STRING`, scope `PROJECT`. - [ ] **Step 3: Document the env contract in README** Append to `apps/mobile/README.md`: ````markdown ## EAS Builds and Environment Variables Production and preview builds load `EXPO_PUBLIC_*` from EAS Secrets — `.env.local` is only honoured by `expo start` locally. Required secrets (create once per project): ```bash npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value '' npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '' npx eas-cli secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback' ``` Check with `npx eas-cli secret:list`. Missing values cause `env.ts` to throw at the first read, which the `` view renders. ```` - [ ] **Step 4: Commit** ```bash git add apps/mobile/README.md git commit -m "docs(mobile): document EAS Secrets contract for EXPO_PUBLIC_*" ``` --- ## Phase 1 — Lazy env proxy (TDD) ### Task 2: Failing test for missing env var **Files:** - Create: `apps/mobile/lib/env.test.ts` - [ ] **Step 1: Write the failing test** Create `apps/mobile/lib/env.test.ts`: ```ts import { beforeEach, describe, expect, it, vi } from 'vitest'; describe('mobile env (lazy proxy)', () => { beforeEach(() => { vi.resetModules(); delete process.env.EXPO_PUBLIC_SUPABASE_URL; delete process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; delete process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL; }); it('importing the module does NOT throw when required vars are missing', async () => { await expect(import('./env')).resolves.toBeTruthy(); }); it('reading a property with no env set throws a clear error', async () => { const mod = await import('./env'); expect(() => mod.env.supabaseUrl).toThrowError( /Missing required env var EXPO_PUBLIC_SUPABASE_URL/, ); }); it('reading a property after setting env returns the value and memoises', async () => { process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co'; process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'anon-123'; const mod = await import('./env'); expect(mod.env.supabaseUrl).toBe('https://example.supabase.co'); expect(mod.env.supabaseAnonKey).toBe('anon-123'); expect(mod.env.authRedirectUrl).toBe('netralax://auth/callback'); }); }); ``` - [ ] **Step 2: Run test, confirm it fails for the right reason** Run: ```bash pnpm --filter @chat-app/mobile test -- env.test ``` Expected: `importing the module does NOT throw when required vars are missing` FAILS because the current `env.ts` evaluates `required(...)` at module-eval time. This proves the test is wired correctly. Do NOT proceed if a different test fails first — fix the wiring before editing source. ### Task 3: Convert `env.ts` to a lazy proxy **Files:** - Modify: `apps/mobile/lib/env.ts` - [ ] **Step 1: Replace the module body with the lazy proxy** Overwrite `apps/mobile/lib/env.ts` with: ```ts // EXPO_PUBLIC_* vars are inlined at bundle time by Expo's Babel plugin (or // shipped via EAS Secrets for EAS builds — see apps/mobile/README.md). // We pull them through a Proxy so missing vars throw on first READ, not at // module-eval time. That keeps the throw inside the React tree where the // boundary can render it as a readable screen instead of a blank // white window. function required(name: string): string { const v = process.env[name]; if (!v || v.length === 0) { throw new Error( 'Missing required env var ' + name + '. Set it via `eas secret:create --scope project --name ' + name + ' --value ...` or in apps/mobile/.env.local for local dev (see .env.example).', ); } return v; } interface EnvShape { supabaseUrl: string; supabaseAnonKey: string; authRedirectUrl: string; } function readEnv(): EnvShape { return { supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'), supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'), authRedirectUrl: process.env.EXPO_PUBLIC_AUTH_REDIRECT_URL ?? 'netralax://auth/callback', }; } let cached: EnvShape | null = null; export const env: EnvShape = new Proxy({} as EnvShape, { get(_target, key: string | symbol): unknown { cached ??= readEnv(); return cached[key as keyof EnvShape]; }, }); ``` - [ ] **Step 2: Re-run the env tests** ```bash pnpm --filter @chat-app/mobile test -- env.test ``` Expected: all three tests PASS. - [ ] **Step 3: Run the full mobile test suite** ```bash pnpm --filter @chat-app/mobile test ``` Expected: no regressions. If a test depended on the old eager-throw behaviour, update it inline to expect a lazy throw. - [ ] **Step 4: Commit** ```bash git add apps/mobile/lib/env.ts apps/mobile/lib/env.test.ts git commit -m "fix(mobile): lazy env proxy so missing EXPO_PUBLIC vars throw inside React" ``` --- ## Phase 2 — Bootstrap boundary + global handler ### Task 4: Create `BootSplash` and `BootError` **Files:** - Create: `apps/mobile/components/BootSplash.tsx` - Create: `apps/mobile/components/BootError.tsx` - [ ] **Step 1: Create the splash** `apps/mobile/components/BootSplash.tsx`: ```tsx import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { colors } from '../theme/colors'; // Shown while AppBootstrap is initialising the crypto backend. Identical // background to the Expo splash so the handoff is invisible to the user. export function BootSplash() { return ( ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg, }, }); ``` - [ ] **Step 2: Create the error view** `apps/mobile/components/BootError.tsx`: ```tsx import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { colors } from '../theme/colors'; interface Props { error: Error; } // Last-resort fallback. Renders whenever AppBootstrap's init effect throws or // when the global JS error handler catches an unhandled exception. The // env-diagnostic line ("env-ok" / "env-missing") makes future bug reports // triageable from a single screenshot. export function BootError({ error }: Props) { const envOk = Boolean(process.env.EXPO_PUBLIC_SUPABASE_URL); return ( App-Start fehlgeschlagen {error.message} EXPO_PUBLIC_SUPABASE_URL: {envOk ? 'env-ok' : 'env-missing'} {error.stack && {error.stack}} ); } const styles = StyleSheet.create({ container: { flexGrow: 1, backgroundColor: colors.bg, padding: 24, paddingTop: 64, gap: 12, }, title: { color: colors.text, fontSize: 20, fontWeight: '700' }, message: { color: colors.danger, fontSize: 14, lineHeight: 20 }, diagnostic: { flexDirection: 'row', gap: 8, marginTop: 8 }, diagnosticLabel: { color: colors.textMuted, fontSize: 12 }, diagnosticValue: { fontSize: 12, fontWeight: '700' }, ok: { color: colors.success }, bad: { color: colors.danger }, stack: { color: colors.textDim, fontSize: 11, fontFamily: 'Courier', marginTop: 16, }, }); ``` - [ ] **Step 3: Commit (no test yet — these are render-only components)** ```bash git add apps/mobile/components/BootSplash.tsx apps/mobile/components/BootError.tsx git commit -m "feat(mobile): BootSplash + BootError fallback views for AppBootstrap" ``` ### Task 5: Create `AppBootstrap` boundary **Files:** - Create: `apps/mobile/components/AppBootstrap.tsx` - [ ] **Step 1: Write the boundary** `apps/mobile/components/AppBootstrap.tsx`: ```tsx import { crypto } from '@chat-app/shared'; import { type ReactNode, useEffect, useState } from 'react'; import { createLibsodiumBackend } from '../lib/cryptoBackend'; import { BootError } from './BootError'; import { BootSplash } from './BootSplash'; interface Props { children: ReactNode; } // React Native exposes ErrorUtils on the global. The types ship with RN but // we cast defensively because the renderer used by Vitest does not. interface RNErrorUtils { getGlobalHandler: () => (err: Error, isFatal?: boolean) => void; setGlobalHandler: (handler: (err: Error, isFatal?: boolean) => void) => void; } // Initialises the crypto backend inside a useEffect (not at module-eval) so // any failure surfaces in the React tree. Also installs a global JS error // handler that routes unhandled throws to BootError; this catches errors // thrown during render (e.g. the lazy env proxy reading a missing var) that // would otherwise escape every per-screen ErrorBoundary. export function AppBootstrap({ children }: Props) { const [error, setError] = useState(null); const [ready, setReady] = useState(false); useEffect(() => { try { crypto.setCryptoBackend(createLibsodiumBackend()); setReady(true); } catch (e: unknown) { setError(e instanceof Error ? e : new Error(String(e))); } }, []); useEffect(() => { const eu = (globalThis as unknown as { ErrorUtils?: RNErrorUtils }).ErrorUtils; if (!eu) return; const prev = eu.getGlobalHandler(); eu.setGlobalHandler((err, isFatal) => { prev?.(err, isFatal); setError(err); }); return () => eu.setGlobalHandler(prev); }, []); if (error) return ; if (!ready) return ; return <>{children}; } ``` - [ ] **Step 2: Typecheck** ```bash pnpm --filter @chat-app/mobile typecheck ``` Expected: no new errors. - [ ] **Step 3: Commit** ```bash git add apps/mobile/components/AppBootstrap.tsx git commit -m "feat(mobile): AppBootstrap boundary — defers crypto init, catches global throws" ``` ### Task 6: Rewire `_layout.tsx` **Files:** - Modify: `apps/mobile/app/_layout.tsx` - [ ] **Step 1: Replace the module-eval crypto init with ``** Overwrite `apps/mobile/app/_layout.tsx` with: ```tsx import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import { AppBootstrap } from '../components/AppBootstrap'; import { ErrorBoundary } from '../components/ErrorBoundary'; import { IncomingCallModal } from '../components/IncomingCallModal'; import { AuthProvider } from '../lib/authContext'; import { CallProvider } from '../lib/callContext'; export default function RootLayout() { return ( ); } ``` Two structural changes vs. the prior version: 1. Removed the top-level `crypto.setCryptoBackend(createLibsodiumBackend())` call — it now runs inside `AppBootstrap`'s `useEffect`. 2. `` sits OUTSIDE `` so a boot failure renders `` instead of trying (and failing) to hit `ErrorBoundary`'s consumer-tree path. - [ ] **Step 2: Typecheck** ```bash pnpm --filter @chat-app/mobile typecheck ``` Expected: no errors. The previously-direct `crypto` + `createLibsodiumBackend` imports are now gone from `_layout.tsx`; if either is reported as unused, remove the stale import. - [ ] **Step 3: Commit** ```bash git add apps/mobile/app/_layout.tsx git commit -m "fix(mobile): defer crypto backend init into AppBootstrap (prevents white-screen)" ``` --- ## Phase 3 — Local validation (sanity before remote build) ### Task 7: Local smoke (Expo Go / dev client) — env-missing path **Files:** - None (local sanity, no edits) - [ ] **Step 1: Temporarily clear local env** ```bash mv apps/mobile/.env.local apps/mobile/.env.local.bak ``` - [ ] **Step 2: Start the dev server** ```bash pnpm --filter @chat-app/mobile dev ``` In a connected Android emulator / device, open the dev client. Expected: app reaches `` with the message `Missing required env var EXPO_PUBLIC_SUPABASE_URL ...` and the diagnostic line `EXPO_PUBLIC_SUPABASE_URL: env-missing`. **No white screen.** - [ ] **Step 3: Restore env** ```bash mv apps/mobile/.env.local.bak apps/mobile/.env.local ``` Reload the dev client. Expected: app boots normally to the login screen. - [ ] **Step 4: No commit (validation only)** No-op. --- ## Phase 4 — Remote build validation (the actual RCA) ### Task 8: Run the diagnostic playbook against a real APK **Files:** - None (investigative; outcome determines whether Phase 5 fixes are needed) - [ ] **Step 1: Build the preview APK with EAS Secrets present** ```bash cd apps/mobile npx eas-cli build --profile preview --platform android ``` Expected: build succeeds. Note the APK URL. - [ ] **Step 2: Install on a connected Android device** ```bash adb install -r .apk ``` Expected: install succeeds. - [ ] **Step 3: Capture logs while launching** ```bash adb logcat -c adb logcat *:E ReactNative:V ReactNativeJS:V & # tap the launcher icon for the app ``` Triage the first 50 lines for the first `Error`, `Exception`, or `FATAL` after the app starts. - [ ] **Step 4: Match the trace against a hypothesis** | Trace pattern | Hypothesis | Next action | |---|---|---| | `Missing required env var EXPO_PUBLIC_…` rendered to BootError (no red box) | H1 — fix already applied | Skip to Phase 6 | | `Native module … not found` / `RNLibsodium not found` | H3 — libsodium native autolink missing | Phase 5 Task 9 | | `JNI DETECTED ERROR` / `Fatal signal 11 (SIGSEGV)` before any RN log | H2 — new arch + incompatible lib | Phase 5 Task 10 | | `libsodium-wrappers-sumo` or `WebAssembly` in the trace | H4 — covered by the mobile-encryption-port plan | Note the trace; merge that plan next | | App reaches login screen | H1 was the root cause; nothing more to do | Skip to Phase 6 | - [ ] **Step 5: Write a one-paragraph note in the PR description** Capture which hypothesis confirmed, log lines, and which Phase 5 task (if any) was needed. This becomes the regression record. --- ## Phase 5 — Hypothesis-specific fixes (conditional) Only run the tasks that the Step 4 triage selected. If H1 alone resolves it, skip Phase 5 entirely. ### Task 9 (conditional, H3): Force re-link `react-native-libsodium` **Files:** - Modify: `apps/mobile/app.json` (only if `expo prebuild` adds a plugin entry — see below) - [ ] **Step 1: Run `expo prebuild` to regenerate native projects** ```bash cd apps/mobile npx expo prebuild --clean --platform android ``` Expected: an `android/` directory is created (or refreshed), and `app.json` may gain a `plugins` entry for `react-native-libsodium` if the lib ships a config plugin. - [ ] **Step 2: Rebuild and re-test** ```bash npx eas-cli build --profile preview --platform android ``` Install, repeat Phase 4 Step 3-4. Expected: native module is now found. If still missing, escalate to the lib's GitHub issues — it likely needs a manual Gradle entry in `android/app/build.gradle`. - [ ] **Step 3: Commit any generated config** If `app.json` changed, commit the diff: ```bash git add apps/mobile/app.json git commit -m "fix(mobile): re-link react-native-libsodium via expo prebuild" ``` If `android/` is ignored (Expo managed flow), document the prebuild step in the README under the "EAS env" section instead. ### Task 10 (conditional, H2): Temporarily disable `newArchEnabled` **Files:** - Modify: `apps/mobile/app.json` - [ ] **Step 1: Toggle the flag** Edit `apps/mobile/app.json`, change `"newArchEnabled": true` → `"newArchEnabled": false`. Note in the commit message which library is suspected and the upstream issue link. - [ ] **Step 2: Rebuild and re-test** ```bash npx eas-cli build --profile preview --platform android ``` Install, repeat Phase 4 Step 3-4. Expected: app boots to login. - [ ] **Step 3: Open a follow-up issue** Create a tracking issue in the repo titled `mobile: re-enable newArchEnabled once is Fabric-ready` with the trace from Phase 4 attached. Link to the affected lib's tracker. - [ ] **Step 4: Commit** ```bash git add apps/mobile/app.json git commit -m "fix(mobile): temporarily disable newArchEnabled (white-screen on Android) Suspected incompatibility: . Tracked in #." ``` --- ## Phase 6 — Optional hardening ### Task 11: `check:env` lint script **Files:** - Create: `apps/mobile/scripts/check-env.mjs` - Modify: `apps/mobile/package.json` - [ ] **Step 1: Add the script** `apps/mobile/scripts/check-env.mjs`: ```js #!/usr/bin/env node // Compares apps/mobile/.env.example with .env.local. Surfaces missing keys // so an onboarding dev doesn't ship a build that white-screens. import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); const mobileRoot = resolve(here, '..'); const examplePath = resolve(mobileRoot, '.env.example'); const localPath = resolve(mobileRoot, '.env.local'); if (!existsSync(localPath)) { console.error('No .env.local found at ' + localPath); console.error('Copy .env.example to .env.local and fill in values.'); process.exit(1); } function keysOf(path) { return new Set( readFileSync(path, 'utf8') .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith('#')) .map((line) => line.split('=', 1)[0]), ); } const exampleKeys = keysOf(examplePath); const localKeys = keysOf(localPath); const missing = [...exampleKeys].filter((k) => !localKeys.has(k)); if (missing.length > 0) { console.error('Missing keys in .env.local: ' + missing.join(', ')); process.exit(1); } console.log('env-ok: all .env.example keys present in .env.local'); ``` - [ ] **Step 2: Wire the script into `package.json`** In `apps/mobile/package.json`, under `scripts`, add: ```json "check:env": "node scripts/check-env.mjs" ``` - [ ] **Step 3: Smoke** ```bash pnpm --filter @chat-app/mobile run check:env ``` Expected: `env-ok: all .env.example keys present in .env.local`. - [ ] **Step 4: Commit** ```bash git add apps/mobile/scripts/check-env.mjs apps/mobile/package.json git commit -m "chore(mobile): check:env script lints .env.local against .env.example" ``` --- ## Phase 7 — Wrap-up ### Task 12: PR + post-mortem note - [ ] **Step 1: Push branch + open PR** ```bash git push -u origin gh pr create --title "fix(mobile): Android white-screen RCA + defense-in-depth" --body "..." ``` PR body must contain: ``` ## Summary - Lazy env proxy so missing EXPO_PUBLIC_* throws inside React. - AppBootstrap boundary mounts before AuthProvider; renders BootError on init failure. - Global ErrorUtils handler routes unhandled throws to BootError. - EAS Secrets documented in README; eas.json untouched. ## RCA outcome ## Test plan - [x] Local: env-cleared dev client shows BootError, not white screen. - [x] Remote: preview APK installed on Android device; . - [x] check:env script passes. - [x] All mobile unit tests green. ``` - [ ] **Step 2: Mark plan complete** This plan is done when the Android preview build opens to the login screen and `` renders correctly with env intentionally cleared. --- ## Spec coverage check - Hypothesis H1 — EAS Secrets (Task 1), lazy env (Task 3), env test (Task 2). ✅ - Hypothesis H2 — Diagnostic (Task 8), conditional Task 10 fix. ✅ - Hypothesis H3 — Diagnostic (Task 8), conditional Task 9 fix. ✅ - Hypothesis H4 — Diagnostic only (Task 8); structural fix belongs to the mobile-encryption-port plan. ✅ - Hypothesis H5 — Documented in Task 8 Step 4 table; cost of an asset eyeball is zero, no separate task needed. ✅ - Defense-in-depth 1 (lazy env) — Tasks 2-3. ✅ - Defense-in-depth 2 (AppBootstrap) — Tasks 4-6. ✅ - Defense-in-depth 3 (global handler) — Task 5. ✅ - Defense-in-depth 4 (SecureStore probe) — deferred (see Out of Scope). ✅ - Defense-in-depth 5 (`check:env`) — Task 11. ✅ ## Out of scope for this plan (in spec, but deferred) - SecureStore availability probe — only relevant on rare Android factory-test profiles. Add later if Phase 4 surfaces a SecureStore symptom. - Sentry / Bugsnag integration — spec lists this under future work.