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:
@@ -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
Reference in New Issue
Block a user