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.
17 KiB
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 — 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
previewandproductionprofile 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:
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.3react-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:
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:
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:
-
EAS Secrets (recommended for production).
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.jsonchange needed; secrets propagate automatically to all profiles. This is the path chosen for this release. -
eas.jsonenvblock (rejected — kept here only as a reference for future profiles where Secrets are not yet provisioned).{ "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:
@livekit/react-native-webrtc— verify against the lib's CHANGELOG that the installed major version declares Fabric support.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.
-
Lazy
env. Convertapps/mobile/lib/env.tsfromexport const env = {...}to a lazy proxy: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. -
Bootstrap boundary.
apps/mobile/app/_layout.tsxintroduces<AppBootstrap>: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}</>; }BootErroris a minimal<View>with the message + a dump ofprocess.env.EXPO_PUBLIC_SUPABASE_URL ? 'env-ok' : 'env-missing'so future white-screen reports can be triaged in one screenshot. -
Global JS error handler. Inside the same boundary, register a fallback for unhandled errors that escape every React-level boundary:
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
envreads in render paths and surfaces them. Negligible runtime overhead. -
SecureStoresmoke probe. Optional, cheap: auseEffectthat callsSecureStore.isAvailableAsync()and reports failure in the sameBootErrorpath. Helps catch the rare Android profile where secure storage is disabled. -
.env.localparity check (lint). Add anpm run check:envscript that compares.env.exampleagainst.env.localto 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):
- With env block / EAS Secret in place:
eas build --profile preview --platform android. Install. App opens to login screen. - With env block intentionally removed locally:
npx expo run:android --no-bundler-reload— theBootErrorview must render with "Missing required env var EXPO_PUBLIC_SUPABASE_URL". No white screen. - Crypto backend simulated failure: temporarily stub
createLibsodiumBackendto throw; verifyBootErrorshows the underlying message. - Global handler smoke: place a
throw new Error('boom')insidesetTimeout(..., 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. AllEXPO_PUBLIC_*variables ship viaeas secret:create --scope project.eas.jsonis not touched for env wiring. Rationale: keepseas.jsonfuture-proof for non-public values; avoids accidental commits. - newArchEnabled rollback acceptable. If H2 confirms, we ship with
"newArchEnabled": falsewhile 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-webrtcif 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.localagainst.env.exampleandeas.json.