From 6cd351420a0bb2e79026df81faa829227f2811fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:53:18 -0700 Subject: [PATCH] fix(mobile): surface previous crash on relaunch --- mobile/app/_layout.tsx | 2 + mobile/app/troubleshoot.tsx | 3 +- .../components/PreviousCrashSessionBanner.tsx | 71 +++++++++++++++--- .../PreviousCrashSessionLaunchNotice.tsx | 60 +++++++++++++++ ...vious-crash-session-launch-notice.test.tsx | 73 +++++++++++++++++++ .../diagnostics/mobile-crash-diagnostics.ts | 8 ++ .../mobile-crash-session-storage.ts | 11 ++- .../diagnostics/mobile-crash-session.test.ts | 39 ++++++++++ .../src/diagnostics/mobile-crash-session.ts | 24 ++++++ 9 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 mobile/src/components/PreviousCrashSessionLaunchNotice.tsx create mode 100644 mobile/src/components/previous-crash-session-launch-notice.test.tsx diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 2b1ba6ec955..10c2991dfe6 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -8,6 +8,7 @@ import * as Linking from 'expo-linking' import { colors } from '../src/theme/mobile-theme' import { OrcaLogo } from '../src/components/OrcaLogo' import { MobileRootErrorBoundary } from '../src/components/MobileRootErrorBoundary' +import { PreviousCrashSessionLaunchNotice } from '../src/components/PreviousCrashSessionLaunchNotice' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' @@ -184,6 +185,7 @@ function RootLayoutContents() { + - {hasPreviousCrashSession && } + {hasPreviousCrashSession && } [ @@ -320,6 +320,7 @@ export default function TroubleshootScreen() { } const styles = StyleSheet.create({ + crashBanner: { marginBottom: spacing.lg }, container: { flex: 1, backgroundColor: colors.bgBase, diff --git a/mobile/src/components/PreviousCrashSessionBanner.tsx b/mobile/src/components/PreviousCrashSessionBanner.tsx index 277d406367c..5f842027ac7 100644 --- a/mobile/src/components/PreviousCrashSessionBanner.tsx +++ b/mobile/src/components/PreviousCrashSessionBanner.tsx @@ -1,17 +1,53 @@ -import { AlertTriangle } from 'lucide-react-native' -import { StyleSheet, Text, View } from 'react-native' +import { AlertTriangle, ChevronRight, X } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native' import { colors, radii, spacing, typography } from '../theme/mobile-theme' -export function PreviousCrashSessionBanner() { +export function PreviousCrashSessionBanner({ + onDismiss, + onPress, + style +}: { + onDismiss?: () => void + onPress?: () => void + style?: StyleProp +}) { + const copy = ( + + Previous session ended abnormally + + Crash diagnostics are available to copy and share with support. + + {onPress ? View diagnostics : null} + + ) + return ( - + - - Previous session ended abnormally - - Crash diagnostics are available to copy and share with support. - - + {onPress ? ( + + {copy} + + + ) : ( + copy + )} + {onDismiss ? ( + + + + ) : null} ) } @@ -22,7 +58,6 @@ const styles = StyleSheet.create({ alignItems: 'flex-start', gap: spacing.md, padding: spacing.md, - marginBottom: spacing.lg, borderWidth: StyleSheet.hairlineWidth, borderColor: colors.borderSubtle, borderRadius: radii.card, @@ -32,6 +67,15 @@ const styles = StyleSheet.create({ flex: 1, gap: spacing.xs }, + open: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + dismiss: { + padding: spacing.xs + }, title: { color: colors.textPrimary, fontSize: typography.bodySize, @@ -41,5 +85,10 @@ const styles = StyleSheet.create({ color: colors.textSecondary, fontSize: typography.metaSize, lineHeight: 18 + }, + actionText: { + color: colors.textPrimary, + fontSize: typography.metaSize, + fontWeight: '600' } }) diff --git a/mobile/src/components/PreviousCrashSessionLaunchNotice.tsx b/mobile/src/components/PreviousCrashSessionLaunchNotice.tsx new file mode 100644 index 00000000000..c0feea8d392 --- /dev/null +++ b/mobile/src/components/PreviousCrashSessionLaunchNotice.tsx @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useState } from 'react' +import { StyleSheet, View } from 'react-native' +import { useRouter } from 'expo-router' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { + dismissPreviousMobileCrashSession, + getUndismissedPreviousMobileCrashSession +} from '../diagnostics/mobile-crash-diagnostics' +import type { MobileCrashSessionSnapshot } from '../diagnostics/mobile-crash-session' +import { colors, spacing } from '../theme/mobile-theme' +import { PreviousCrashSessionBanner } from './PreviousCrashSessionBanner' + +export function PreviousCrashSessionLaunchNotice() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [previousCrash, setPreviousCrash] = useState(null) + + useEffect(() => { + let active = true + void getUndismissedPreviousMobileCrashSession().then((session) => { + if (active) { + setPreviousCrash(session) + } + }) + return () => { + active = false + } + }, []) + + const dismiss = useCallback(() => { + if (!previousCrash) { + return + } + setPreviousCrash(null) + void dismissPreviousMobileCrashSession(previousCrash.openedAt) + }, [previousCrash]) + + const openTroubleshooting = useCallback(() => { + dismiss() + router.push('/troubleshoot') + }, [dismiss, router]) + + if (!previousCrash) { + return null + } + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: spacing.md, + paddingBottom: spacing.sm, + backgroundColor: colors.bgBase + } +}) diff --git a/mobile/src/components/previous-crash-session-launch-notice.test.tsx b/mobile/src/components/previous-crash-session-launch-notice.test.tsx new file mode 100644 index 00000000000..4cbdcb85e5d --- /dev/null +++ b/mobile/src/components/previous-crash-session-launch-notice.test.tsx @@ -0,0 +1,73 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + dismiss: vi.fn().mockResolvedValue(undefined), + getUndismissed: vi.fn(), + push: vi.fn() +})) + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 20 }) +})) +vi.mock('expo-router', () => ({ useRouter: () => ({ push: mocks.push }) })) +vi.mock('lucide-react-native', () => ({ + AlertTriangle: 'AlertTriangle', + ChevronRight: 'ChevronRight', + X: 'X' +})) +vi.mock('../diagnostics/mobile-crash-diagnostics', () => ({ + dismissPreviousMobileCrashSession: mocks.dismiss, + getUndismissedPreviousMobileCrashSession: mocks.getUndismissed +})) + +import { PreviousCrashSessionLaunchNotice } from './PreviousCrashSessionLaunchNotice' + +const previousCrash = { + openedAt: '2026-08-24T18:00:00.000Z', + breadcrumbs: [] +} + +describe('PreviousCrashSessionLaunchNotice', () => { + beforeEach(() => { + mocks.dismiss.mockClear() + mocks.getUndismissed.mockReset() + mocks.push.mockClear() + }) + + it('shows an abnormal-session report on launch and opens Troubleshooting', async () => { + mocks.getUndismissed.mockResolvedValue(previousCrash) + let renderer!: ReactTestRenderer + await act(async () => { + renderer = create(createElement(PreviousCrashSessionLaunchNotice)) + await Promise.resolve() + }) + + expect(renderer.root.findByProps({ testID: 'previous-crash-session-banner' })).toBeDefined() + const open = renderer.root.findByProps({ accessibilityLabel: 'View crash diagnostics' }) + await act(async () => open.props.onPress()) + + expect(mocks.dismiss).toHaveBeenCalledWith(previousCrash.openedAt) + expect(mocks.push).toHaveBeenCalledWith('/troubleshoot') + }) + + it('does not add a launch surface when there is no report', async () => { + mocks.getUndismissed.mockResolvedValue(null) + let renderer!: ReactTestRenderer + await act(async () => { + renderer = create(createElement(PreviousCrashSessionLaunchNotice)) + await Promise.resolve() + }) + + expect(renderer.root.findAllByProps({ testID: 'previous-crash-session-banner' })).toHaveLength( + 0 + ) + }) +}) diff --git a/mobile/src/diagnostics/mobile-crash-diagnostics.ts b/mobile/src/diagnostics/mobile-crash-diagnostics.ts index 6b097c3f87d..955b80c5097 100644 --- a/mobile/src/diagnostics/mobile-crash-diagnostics.ts +++ b/mobile/src/diagnostics/mobile-crash-diagnostics.ts @@ -28,6 +28,14 @@ export function getPreviousMobileCrashSession(): Promise { + return journal.getUndismissedLatestAbnormalSession() +} + +export function dismissPreviousMobileCrashSession(openedAt: string): Promise { + return journal.dismissLatestAbnormalSession(openedAt) +} + export function buildMobileCrashDiagnosticsReport(): Promise { return journal.buildReport({ version: Constants.expoConfig?.version ?? 'unknown', diff --git a/mobile/src/diagnostics/mobile-crash-session-storage.ts b/mobile/src/diagnostics/mobile-crash-session-storage.ts index 337bcf7dfab..75a159d6a31 100644 --- a/mobile/src/diagnostics/mobile-crash-session-storage.ts +++ b/mobile/src/diagnostics/mobile-crash-session-storage.ts @@ -23,6 +23,7 @@ export type PersistedMobileCrashJournal = { version: 1 activeSession: PersistedMobileCrashSession latestAbnormalSession?: MobileCrashSessionSnapshot + dismissedAbnormalSessionOpenedAt?: string } export const MOBILE_CRASH_SESSION_STORAGE_KEY = 'orca.mobile-crash-session.v1' @@ -52,10 +53,15 @@ export function parseMobileCrashJournal(raw: string): PersistedMobileCrashJourna return null } const latestAbnormalSession = parseSnapshot(parsed.latestAbnormalSession) + const dismissedAbnormalSessionOpenedAt = + typeof parsed.dismissedAbnormalSessionOpenedAt === 'string' + ? sanitizeCrashReportString(parsed.dismissedAbnormalSessionOpenedAt, 80) + : null return { version: 1, activeSession, - ...(latestAbnormalSession ? { latestAbnormalSession } : {}) + ...(latestAbnormalSession ? { latestAbnormalSession } : {}), + ...(dismissedAbnormalSessionOpenedAt ? { dismissedAbnormalSessionOpenedAt } : {}) } } catch { return null @@ -76,6 +82,9 @@ export function serializeMobileCrashJournal(journal: PersistedMobileCrashJournal breadcrumbs: [...journal.latestAbnormalSession.breadcrumbs] } } + : {}), + ...(journal.dismissedAbnormalSessionOpenedAt + ? { dismissedAbnormalSessionOpenedAt: journal.dismissedAbnormalSessionOpenedAt } : {}) } let serialized = JSON.stringify(bounded) diff --git a/mobile/src/diagnostics/mobile-crash-session.test.ts b/mobile/src/diagnostics/mobile-crash-session.test.ts index ab4e28c40f0..5ec31a95666 100644 --- a/mobile/src/diagnostics/mobile-crash-session.test.ts +++ b/mobile/src/diagnostics/mobile-crash-session.test.ts @@ -149,11 +149,50 @@ describe('mobile crash session journal', () => { }) await expect(second.start()).resolves.toBeNull() + await expect(second.getUndismissedLatestAbnormalSession()).resolves.toBeNull() await expect( second.buildReport({ version: '0.0.29', platform: 'android 15' }) ).resolves.toContain('No previous abnormal session recorded.') }) + it('keeps a report dismissed across a clean relaunch without hiding the next crash', async () => { + const storage = new MemoryStorage() + const first = new MobileCrashSessionJournal(storage, { + now: () => FIRST_SESSION_AT, + createSessionId: () => 'session-one' + }) + await first.start() + + const second = new MobileCrashSessionJournal(storage, { + now: () => SECOND_SESSION_AT, + createSessionId: () => 'session-two' + }) + const previous = await second.start() + expect(previous).not.toBeNull() + await expect(second.getUndismissedLatestAbnormalSession()).resolves.toEqual(previous) + + await second.dismissLatestAbnormalSession(previous?.openedAt ?? '') + await second.recordAppState('background') + + const third = new MobileCrashSessionJournal(storage, { + now: () => SECOND_SESSION_AT + 30_000, + createSessionId: () => 'session-three' + }) + await third.start() + await expect(third.getUndismissedLatestAbnormalSession()).resolves.toBeNull() + + await third.recordRenderError(new Error('a different crash')) + await third.recordAppState('background') + const fourth = new MobileCrashSessionJournal(storage, { + now: () => SECOND_SESSION_AT + 60_000, + createSessionId: () => 'session-four' + }) + await fourth.start() + await expect(fourth.getUndismissedLatestAbnormalSession()).resolves.toMatchObject({ + openedAt: new Date(SECOND_SESSION_AT + 30_000).toISOString() + }) + }) + it('reopens the marker when a backgrounded process becomes active again', async () => { const storage = new MemoryStorage() const first = new MobileCrashSessionJournal(storage, { diff --git a/mobile/src/diagnostics/mobile-crash-session.ts b/mobile/src/diagnostics/mobile-crash-session.ts index a30bd8535c8..801d38f11c0 100644 --- a/mobile/src/diagnostics/mobile-crash-session.ts +++ b/mobile/src/diagnostics/mobile-crash-session.ts @@ -102,6 +102,9 @@ export class MobileCrashSessionJournal { activeSession, ...(abnormal || stored?.latestAbnormalSession ? { latestAbnormalSession: abnormal ?? stored?.latestAbnormalSession } + : {}), + ...(stored?.dismissedAbnormalSessionOpenedAt + ? { dismissedAbnormalSessionOpenedAt: stored.dismissedAbnormalSessionOpenedAt } : {}) } await this.persistJournal() @@ -146,6 +149,27 @@ export class MobileCrashSessionJournal { return this.enqueue(async () => this.journal?.latestAbnormalSession ?? null) } + async getUndismissedLatestAbnormalSession(): Promise { + await this.start() + return this.enqueue(async () => { + const latest = this.journal?.latestAbnormalSession + return latest?.openedAt === this.journal?.dismissedAbnormalSessionOpenedAt + ? null + : (latest ?? null) + }) + } + + async dismissLatestAbnormalSession(openedAt: string): Promise { + await this.start() + await this.enqueue(async () => { + if (!this.journal || this.journal.latestAbnormalSession?.openedAt !== openedAt) { + return + } + this.journal.dismissedAbnormalSessionOpenedAt = openedAt + await this.persistJournal() + }) + } + async buildReport(app: { version: string; platform: string }): Promise { await this.start() return this.enqueue(async () => {