Files
ChatApp/docs/superpowers/plans/2026-05-16-android-whitescreen-rca.md
byGalax affff0b433 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.
2026-05-16 16:01:51 +02:00

24 KiB

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:

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):

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:

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:

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

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:

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:

// 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
pnpm --filter @chat-app/mobile test -- env.test

Expected: all three tests PASS.

  • Step 3: Run the full mobile test suite
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
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:

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:

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)
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:

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
pnpm --filter @chat-app/mobile typecheck

Expected: no new errors.

  • Step 3: Commit
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:

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

mv apps/mobile/.env.local apps/mobile/.env.local.bak
  • Step 2: Start the dev server
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
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

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
adb install -r <downloaded-apk>.apk

Expected: install succeeds.

  • Step 3: Capture logs while launching
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.

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

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
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:

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
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
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:

#!/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:

"check:env": "node scripts/check-env.mjs"
  • Step 3: Smoke
pnpm --filter @chat-app/mobile run check:env

Expected: env-ok: all .env.example keys present in .env.local.

  • Step 4: Commit
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
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.