# 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.
