docs(plan): mobile encryption port + Android white-screen RCA plans

Two implementation plans for the 2026-05-16 specs.

- Android white-screen: 12 tasks across 7 phases. Phase 0 wires EAS Secrets,
  Phase 1-2 ship the lazy env proxy + AppBootstrap boundary + global JS
  error handler, Phase 3-4 validate against a real APK, Phase 5 has
  conditional hypothesis-specific fixes, Phase 6-7 close out.

- Mobile encryption port: 24 tasks across 8 phases. Extends shared
  CryptoBackend with pwhash + scalarMultBase (the change that lets mobile
  stop loading libsodium-wrappers-sumo in Hermes), refactors desktop
  derivePublicKey through the same backend, mirrors the desktop
  userIdentity orchestrator and Auth flow on RN with new PinInput, setup,
  unlock, and security-settings screens, updates every device-keyed call
  site, and ends with a manual Android smoke list.

Each plan ships with a spec-coverage checklist and explicit out-of-scope
list. White-screen plan must land first; mobile-encryption plan depends
on AppBootstrap deferring crypto init.
This commit is contained in:
byGalax
2026-05-16 16:01:51 +02:00
parent a5c0889b0a
commit affff0b433
2 changed files with 3034 additions and 0 deletions
@@ -0,0 +1,779 @@
# 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 `<AppBootstrap>` boundary inside `_layout.tsx` that initialises the crypto backend in a `useEffect`, renders a splash while loading, and routes any error to a `<BootError>` 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 `<AppBootstrap>` 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 '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
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 '<project-supabase-url>'
npx eas-cli secret:create --scope project --name EXPO_PUBLIC_SUPABASE_ANON_KEY --value '<sb_publishable_key>'
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 `<BootError>` 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
// <BootError> 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 (
<View style={styles.container}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
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 (
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>App-Start fehlgeschlagen</Text>
<Text style={styles.message}>{error.message}</Text>
<View style={styles.diagnostic}>
<Text style={styles.diagnosticLabel}>EXPO_PUBLIC_SUPABASE_URL:</Text>
<Text style={[styles.diagnosticValue, envOk ? styles.ok : styles.bad]}>
{envOk ? 'env-ok' : 'env-missing'}
</Text>
</View>
{error.stack && <Text style={styles.stack}>{error.stack}</Text>}
</ScrollView>
);
}
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<Error | null>(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 <BootError error={error} />;
if (!ready) return <BootSplash />;
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 `<AppBootstrap>`**
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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AppBootstrap>
<ErrorBoundary>
<AuthProvider>
<CallProvider>
<StatusBar style="auto" />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="(app)" />
<Stack.Screen name="auth/callback" />
</Stack>
<IncomingCallModal />
</CallProvider>
</AuthProvider>
</ErrorBoundary>
</AppBootstrap>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
```
Two structural changes vs. the prior version:
1. Removed the top-level `crypto.setCryptoBackend(createLibsodiumBackend())` call — it now runs inside `AppBootstrap`'s `useEffect`.
2. `<AppBootstrap>` sits OUTSIDE `<ErrorBoundary>` so a boot failure renders `<BootError>` 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 `<BootError>` 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 <downloaded-apk>.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 <lib> 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: <library@version>. Tracked in #<issue>."
```
---
## 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 <branch-name>
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
<paste Phase 4 Step 5 paragraph>
## Test plan
- [x] Local: env-cleared dev client shows BootError, not white screen.
- [x] Remote: preview APK installed on Android device; <result>.
- [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 `<BootError>` 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.
File diff suppressed because it is too large Load Diff