Files
ChatApp/docs/superpowers/specs/2026-05-16-android-whitescreen-rca-design.md
byGalax a5c0889b0a docs: mobile encryption-UX port + Android white-screen RCA specs (2026-05-16)
Two specs from the 2026-05-16 brainstorming session:

- mobile encryption-UX port: bring apps/mobile to feature parity with
  desktop v0.18.x user-key/PIN identity. Includes the shared CryptoBackend
  extension (pwhash + scalarMultBase) that removes the libsodium-wrappers-sumo
  Hermes blocker.

- Android white-screen RCA: five ranked hypotheses, ordered diagnostic
  playbook (env-missing, newArch, module-eval crypto init, shared sodium
  side-effects, asset paths), plus defense-in-depth (lazy env proxy,
  AppBootstrap boundary, global JS error handler) that ships regardless
  of which hypothesis confirms.

User decisions resolved at the review gate:
- EAS Secrets for EXPO_PUBLIC_* (not eas.json env block).
- newArchEnabled: false acceptable as a temporary rollback if H2 confirms.
2026-05-16 15:46:40 +02:00

305 lines
17 KiB
Markdown

# Android White-Screen RCA — Design
**Date:** 2026-05-16
**Scope:** Diagnose and fix the white-screen-after-install symptom reported on Android for the `apps/mobile` (Expo SDK 52 / RN 0.76 / new architecture) build. Includes both an ordered diagnostic playbook and defense-in-depth changes we ship regardless of which hypothesis turns out to be the root cause, so a future regression of the same shape lands inside the ErrorBoundary rather than leaving users at a blank window.
**Status:** Approved by user (verbal, sections covered in brainstorming).
**Related:** [`2026-05-16-mobile-encryption-ux-port-design.md`](./2026-05-16-mobile-encryption-ux-port-design.md) — depends on the env-fix in this spec landing first.
## Problem
User-reported symptom: installing the Android build (likely a `preview` or `production` EAS build) produces a fully white screen after the launcher icon is tapped. No native crash, no recoverable error UI, no logs visible to the user. The desktop and dev builds work, so the failure is bound to Android release-mode bundling, the New Architecture toggle, or a module-eval throw before the React tree mounts.
The current `ErrorBoundary` (`apps/mobile/components/ErrorBoundary.tsx`) is mounted inside `_layout.tsx`. Any throw *before* `_layout.tsx`'s default export runs — including throws from `import` side effects — bypasses it entirely. The empty splash screen lingers, then React mounts nothing, leaving a white window.
## Goals
- Identify the root cause empirically by running an ordered diagnostic playbook on a real Android build.
- Land a defense-in-depth patch that ensures future boot-time errors are visible to the user, not silent.
- Restore Android installability of `preview` and `production` profile builds.
## Non-Goals
- Adding native error reporting (Sentry, Bugsnag). Tracked separately.
- A general refactor of bootstrap order. Touches stay minimal and surgical.
- Diagnosing iOS bootstrap issues (no symptom reported there; the hardening here helps iOS regardless).
## Hypotheses (ordered by likelihood)
Confidence is judged from static evidence: file content, package.json, eas.json, manifest, and the boot-order of module imports.
### H1 — Missing `EXPO_PUBLIC_*` env vars in the built APK *(highest confidence)*
`apps/mobile/lib/env.ts`:
```ts
function required(name: string): string {
const v = process.env[name];
if (!v || v.length === 0) {
throw new Error('Missing required env var ' + name + '. ...');
}
return v;
}
export const env = {
supabaseUrl: required('EXPO_PUBLIC_SUPABASE_URL'),
supabaseAnonKey: required('EXPO_PUBLIC_SUPABASE_ANON_KEY'),
// ...
};
```
The `export const env = { ... }` evaluates the moment any importer reaches this module. `_layout.tsx``AuthProvider``supabase.ts``env.ts`. So this runs at app boot, **before** the React tree mounts, **before** the `ErrorBoundary` exists.
Expo only inlines `EXPO_PUBLIC_*` from `.env` files when bundling locally with `expo start`. EAS Build does **not** read `.env.local`. The contract is that the project's `eas.json` either declares an `env` block per profile or relies on EAS Secrets created via `eas secret:create`. Today's `apps/mobile/eas.json` has no `env` keys in any profile.
→ The APK is shipped with `process.env.EXPO_PUBLIC_SUPABASE_URL === undefined``required()` throws → module-eval failure → React never mounts → white screen.
The Android-only framing is incidental: the user simply hasn't tested iOS in this configuration yet; iOS would white-screen too with the same APK contents.
### H2 — `newArchEnabled: true` + an incompatible native lib *(medium-high confidence)*
`app.json` sets `"newArchEnabled": true`. Several native deps installed are not yet uniformly bridgeless / Fabric-ready as of SDK 52:
- `react-native-libsodium` ^1.3.0
- `@livekit/react-native-webrtc` ^144.0.0
- `@config-plugins/react-native-webrtc` ^10.0.0
- `@livekit/react-native` ^2.10.3
- `react-native-gesture-handler` ^2.20.2 (generally fine but historically a culprit on Android Fabric)
If any of these crashes at JNI link time, the JS bundle never runs — fully white window because the Android shell waits for the JS thread to send its first frame.
### H3 — `crypto.setCryptoBackend(createLibsodiumBackend())` at module top-level *(medium confidence)*
`apps/mobile/app/_layout.tsx` line 13 calls `crypto.setCryptoBackend(createLibsodiumBackend())` at module-eval. `createLibsodiumBackend` reads constants like `s.crypto_box_NONCEBYTES`. If `react-native-libsodium`'s native module isn't autolinked (pnpm symlinking + prebuild without an explicit pod / Gradle entry occasionally produces this), `s.crypto_box_NONCEBYTES` is `undefined`. That alone doesn't throw, but the backend object then carries `nonceLength: undefined`. A later code path that reads `nonceLength` and calls `randomBytes(undefined)` throws asynchronously and either produces a red box (dev) or a silent failure (release).
Weaker hypothesis on its own — usually masked by H1 or H2 — but worth ruling out.
### H4 — Module-eval side effects in `@chat-app/shared` *(low-medium confidence)*
`packages/shared/src/crypto/userKey.ts` does `import sodium from 'libsodium-wrappers-sumo'`. If anything in the mobile entrypoint reaches into the shared `crypto` index, libsodium-wrappers-sumo's module body runs in Hermes. Sumo is the "compatibility build" and is known to fail to instantiate on Hermes; even on success its global side effects (`globalThis.crypto`) can collide.
Resolved as part of the mobile-encryption spec (the `CryptoBackend` extension removes the direct `libsodium-wrappers-sumo` dependency from shared). Diagnostic-only entry here.
### H5 — Asset / splash path or manifest issue *(low confidence)*
`app.json` references `./assets/icon.png`, `./assets/adaptive-icon.png`, `./assets/splash.png`. If any of these were lost during a `git mv` or rename, EAS Build would still succeed but the Android launcher could choke. Symptom would more likely be "cannot install" or a missing icon, not pure white, so this is the lowest-likelihood branch.
## Architecture
### Diagnostic playbook (the actual investigation)
Order matters — each step rules out a hypothesis with a minimum-cost action.
**Step 0 — Capture logs.** With the user-reported APK on a connected Android device:
```bash
adb logcat -c
adb logcat *:E ReactNative:V ReactNativeJS:V
# launch the app
```
Triage the first 50 lines for `Error`, `Exception`, `FATAL`. The matching hypothesis determines which fix below to apply first.
**Step 1 — Validate H1.** Even without logs, this is mechanically falsifiable:
```bash
cd apps/mobile
npx expo export --platform android --dev false --output-dir /tmp/expo-android-export
grep -r "Missing required env var" /tmp/expo-android-export/_expo/static/js || true
grep -r "EXPO_PUBLIC_SUPABASE_URL" /tmp/expo-android-export/_expo/static/js || true
```
If "Missing required env var" appears as a literal in the bundle (it will, because it's a thrown Error string), and a Supabase URL string does not appear, H1 is confirmed.
**Step 2 — Validate H2.** Toggle `newArchEnabled: false` in `app.json`, `eas build --profile preview --platform android`, install, retest. If the white-screen disappears, H2 holds. (Do not ship with new-arch off; the fix is to upgrade or replace the incompatible lib, not to permanently disable new-arch.)
**Step 3 — Validate H3.** With the env fix in place (or temporarily hardcoded values), wrap the `crypto.setCryptoBackend(...)` call in `try/catch` that surfaces to a fallback `<View>` (see "Defense-in-depth"). If the fallback now renders, H3 was real.
**Step 4 — Validate H4.** Only after H1 / H2 / H3 are eliminated. Run the bundle through `metro` with verbose logging; look for `libsodium-wrappers-sumo` in the trace. The mobile-encryption port spec removes this risk structurally.
**Step 5 — H5 sweep.** `ls apps/mobile/assets` — confirm every path in `app.json` resolves to an actual file.
### Fixes per hypothesis
**Fix H1 — Wire env vars into EAS builds (REQUIRED — ship regardless of which RCA hypothesis confirms).**
Two acceptable paths:
1. **EAS Secrets (recommended for production).**
```bash
eas secret:create --scope project --name EXPO_PUBLIC_SUPABASE_URL --value 'https://<project>.supabase.co'
eas secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value 'sb_publishable_...'
eas secret:create --scope project --name EXPO_PUBLIC_AUTH_REDIRECT_URL --value 'netralax://auth/callback'
```
No `eas.json` change needed; secrets propagate automatically to all profiles. **This is the path chosen for this release.**
2. **`eas.json` `env` block (rejected — kept here only as a reference for future profiles where Secrets are not yet provisioned).**
```json
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"env": {
"EXPO_PUBLIC_SUPABASE_URL": "https://<project>.supabase.co",
"EXPO_PUBLIC_SUPABASE_ANON_KEY": "sb_publishable_...",
"EXPO_PUBLIC_AUTH_REDIRECT_URL": "netralax://auth/callback"
},
"ios": { "simulator": true },
"android": { "buildType": "apk" }
},
"preview": { "...": "same env block" },
"production": { "...": "same env block" }
}
}
```
Pick one consistently across profiles.
**Fix H2 — newArchEnabled gating.** Identify the incompatible lib. Probable suspect order:
1. `@livekit/react-native-webrtc` — verify against the lib's CHANGELOG that the installed major version declares Fabric support.
2. `react-native-libsodium` — same check.
If a lib is not yet new-arch-ready, the temporary fix is `"newArchEnabled": false` in `app.json` and to file an issue upstream. The permanent fix is an upgrade or replacement (`react-native-sodium-jsi`, `op-sqlite`-style native modules).
**Fix H3 — Defer crypto backend init to React lifecycle.** Move the `crypto.setCryptoBackend(createLibsodiumBackend())` call out of module top-level into a `useEffect` inside an `<AppBootstrap>` boundary. While the backend is initialising, render an `ActivityIndicator`; on failure, render a fallback `<View>` with the error text. This makes any constants-undefined failure user-visible rather than silent. (Also fulfilled by the mobile-encryption spec.)
**Fix H4 — Backend extension (covered by mobile-encryption spec).** Once shared no longer imports `libsodium-wrappers-sumo` at module level, this risk disappears.
**Fix H5 — Repair asset paths.** If a file is missing, `git mv` it back to the path declared in `app.json` or update `app.json` to match.
### Defense-in-depth (ship regardless of root cause)
These changes ship as part of this spec because they harden bootup against any future boot-time throw of the same shape. None of them is a workaround for the actual RCA — they ensure the next failure produces a readable screen, not a white one.
1. **Lazy `env`.** Convert `apps/mobile/lib/env.ts` from `export const env = {...}` to a lazy proxy:
```ts
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 or eas.json env block.');
}
return v;
}
type 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 = new Proxy({} as EnvShape, {
get(_t, key) {
cached ??= readEnv();
return cached[key as keyof EnvShape];
},
});
```
Effect: missing env vars throw the first time someone reads `env.supabaseUrl`, which happens inside React, where `<ErrorBoundary>` is mounted and can render the message.
2. **Bootstrap boundary.** `apps/mobile/app/_layout.tsx` introduces `<AppBootstrap>`:
```tsx
function AppBootstrap({ children }: { children: ReactNode }) {
const [error, setError] = useState<Error | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
crypto.setCryptoBackend(createLibsodiumBackend());
setReady(true);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
}
}, []);
if (error) return <BootError error={error} />;
if (!ready) return <BootSplash />;
return <>{children}</>;
}
```
`BootError` is a minimal `<View>` with the message + a dump of `process.env.EXPO_PUBLIC_SUPABASE_URL ? 'env-ok' : 'env-missing'` so future white-screen reports can be triaged in one screenshot.
3. **Global JS error handler.** Inside the same boundary, register a fallback for unhandled errors that escape every React-level boundary:
```ts
useEffect(() => {
const prev = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((err, isFatal) => {
prev?.(err, isFatal);
setError(err);
});
return () => ErrorUtils.setGlobalHandler(prev);
}, []);
```
This catches throws that happen during e.g. lazy `env` reads in render paths and surfaces them. Negligible runtime overhead.
4. **`SecureStore` smoke probe.** Optional, cheap: a `useEffect` that calls `SecureStore.isAvailableAsync()` and reports failure in the same `BootError` path. Helps catch the rare Android profile where secure storage is disabled.
5. **`.env.local` parity check (lint).** Add a `npm run check:env` script that compares `.env.example` against `.env.local` to surface missing keys in dev. Cheap insurance against the same class of bug recurring during onboarding.
## Data Flow
This spec changes no data flow. The runtime data flow remains: env vars in → React mounts → AuthProvider → screens. The only structural change is *when* `env` is read (lazily, inside React) and *where* crypto backend init runs (inside a React effect).
## Components Touched
| File | Change |
|------|--------|
| `apps/mobile/eas.json` | Add `env` blocks (or document EAS Secret names) per profile. Ship as part of this spec. |
| `apps/mobile/lib/env.ts` | Convert to lazy proxy; preserves the public surface. |
| `apps/mobile/app/_layout.tsx` | Introduce `<AppBootstrap>` + `<BootError>` + `<BootSplash>`; move `setCryptoBackend` call into the bootstrap effect; install global JS error handler. |
| `apps/mobile/components/BootError.tsx` | NEW. Minimal fallback that renders the error message + env-diagnostic line. |
| `apps/mobile/components/ErrorBoundary.tsx` | No change in behaviour; remains the per-screen boundary. |
| `apps/mobile/README.md` | Add an "EAS env" section pointing at `eas secret:create` / `eas.json env`. |
| `apps/mobile/package.json` | Optional `check:env` script. |
## Error Handling
| Case | Behaviour |
|------|-----------|
| Missing `EXPO_PUBLIC_*` at runtime | `BootError` renders with the specific variable name and the env-missing diagnostic. |
| Crypto backend constants undefined | `BootError` renders; user sees "Crypto-Backend konnte nicht geladen werden" + the underlying message. |
| `SecureStore` unavailable | `BootError` with explicit hint; app does not boot further. |
| Global unhandled JS error | Global handler routes to `BootError` (or whatever screen is currently mounted, via the per-screen `ErrorBoundary`). |
| All H-fixes applied; new bug appears | The defense-in-depth path catches it; we get a stack instead of a white screen. |
## Testing
**Manual (the real validation — pre-merge):**
1. With env block / EAS Secret in place: `eas build --profile preview --platform android`. Install. App opens to login screen.
2. With env block intentionally removed locally: `npx expo run:android --no-bundler-reload` — the `BootError` view must render with "Missing required env var EXPO_PUBLIC_SUPABASE_URL". No white screen.
3. Crypto backend simulated failure: temporarily stub `createLibsodiumBackend` to throw; verify `BootError` shows the underlying message.
4. Global handler smoke: place a `throw new Error('boom')` inside `setTimeout(..., 100)` in `_layout.tsx`; verify it surfaces.
**Automated (cheap, ship with):**
- `apps/mobile/lib/env.test.ts` — lazy proxy returns env var when set; throws on first read when missing; subsequent reads memoise.
- `apps/mobile/components/BootError.test.tsx` — renders message + env diagnostic.
## Decisions (resolved at brainstorming user-review gate)
- **EAS Secret over `eas.json env`.** All `EXPO_PUBLIC_*` variables ship via `eas secret:create --scope project`. `eas.json` is not touched for env wiring. Rationale: keeps `eas.json` future-proof for non-public values; avoids accidental commits.
- **newArchEnabled rollback acceptable.** If H2 confirms, we ship with `"newArchEnabled": false` while the offending lib is upgraded or replaced. Re-enable in a follow-up release.
## Out of Scope (future work)
- Sentry / Bugsnag integration so future regressions are auto-reported.
- Migrating off `@livekit/react-native-webrtc` if it turns out to be the new-arch blocker.
- iOS-specific bootstrap hardening (no symptom reported there; the changes here help iOS anyway).
- A `doctor`-style CLI command that lints `.env.local` against `.env.example` and `eas.json`.