import AsyncStorage from "@react-native-async-storage/async-storage"; import { AppState, AppStateStatus, Platform } from "react-native"; import * as Sentry from "@sentry/react-native"; import * as Updates from "expo-updates"; import { lastObservedRootRouteRef } from "@/lib/rootNavigation"; const STORAGE_KEY = "@jrshikoku/app_crash_sentinel_v1"; const HEARTBEAT_INTERVAL_MS = 7000; const RECENT_HEARTBEAT_MS = 120000; const MIN_SESSION_AGE_MS = 5000; type RootNavigationSnapshot = { rootRoute: string | null; nestedRoute?: string | null; rootIndex?: number; nestedIndex?: number; source?: string; updatedAt: string; }; type CrashSentinelState = { app_session_active: boolean; normal_background: boolean; sessionStartedAt: string; lastHeartbeatAt: string; lastKnownAppState: AppStateStatus | "unknown"; lastRootTab: string | null; previousRootNavigation: RootNavigationSnapshot | null; activeWebViews: string[]; nativeScreensMode: string; reportedUnexpectedExitForSessionStartedAt?: string; }; type StartOptions = { nativeScreensMode: string; }; let currentState: CrashSentinelState | null = null; let heartbeatTimer: ReturnType | null = null; let appStateSubscription: { remove: () => void } | null = null; let latestNavigationSnapshot: RootNavigationSnapshot | null = null; const activeWebViews = new Set(); const nowIso = () => new Date().toISOString(); const safeJsonParse = (value: string | null): T | null => { if (!value) return null; try { return JSON.parse(value) as T; } catch { return null; } }; const getExpoUpdateContext = () => ({ update_id: (Updates as any).updateId ?? null, updateId: (Updates as any).updateId ?? null, channel: (Updates as any).channel ?? null, runtimeVersion: (Updates as any).runtimeVersion ?? null, createdAt: (Updates as any).createdAt ?? null, isEmbeddedLaunch: (Updates as any).isEmbeddedLaunch ?? null, }); const getMemoryInfo = () => { const performanceMemory = (globalThis as any)?.performance?.memory; if (!performanceMemory) return null; return { jsHeapSizeLimit: performanceMemory.jsHeapSizeLimit ?? null, totalJSHeapSize: performanceMemory.totalJSHeapSize ?? null, usedJSHeapSize: performanceMemory.usedJSHeapSize ?? null, }; }; const getRootTab = () => lastObservedRootRouteRef.current ?? currentState?.lastRootTab ?? "unknown"; const persistCurrentState = async (patch?: Partial) => { if (!currentState) return; currentState = { ...currentState, ...patch, lastRootTab: getRootTab(), previousRootNavigation: latestNavigationSnapshot ?? currentState.previousRootNavigation, activeWebViews: Array.from(activeWebViews).sort(), }; try { await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(currentState)); } catch (error) { Sentry.addBreadcrumb({ category: "app.lifecycle", level: "warning", message: "crash sentinel persist failed", data: { errorMessage: error instanceof Error ? error.message : String(error), }, }); } }; const shouldReportUnexpectedExit = (previous: CrashSentinelState, nowMs: number) => { if (!previous.app_session_active) return false; if (previous.normal_background) return false; if (previous.reportedUnexpectedExitForSessionStartedAt === previous.sessionStartedAt) return false; const heartbeatMs = Date.parse(previous.lastHeartbeatAt); const startedMs = Date.parse(previous.sessionStartedAt); if (!Number.isFinite(heartbeatMs) || !Number.isFinite(startedMs)) return false; const heartbeatAgeMs = nowMs - heartbeatMs; const sessionAgeMs = heartbeatMs - startedMs; return heartbeatAgeMs >= 0 && heartbeatAgeMs <= RECENT_HEARTBEAT_MS && sessionAgeMs >= MIN_SESSION_AGE_MS; }; const reportUnexpectedExit = async (previous: CrashSentinelState, nativeScreensMode: string) => { const rootTab = getRootTab(); Sentry.captureMessage("app.previous_session_unexpected_exit", { level: "warning", tags: { area: "app_lifecycle", result: "unexpected_exit", platform: Platform.OS, root_tab: rootTab, last_root_tab: previous.lastRootTab ?? "unknown", native_screens_mode: nativeScreensMode, }, contexts: { app_lifecycle: { lastHeartbeatAt: previous.lastHeartbeatAt, sessionStartedAt: previous.sessionStartedAt, previousRootNavigation: previous.previousRootNavigation, lastKnownAppState: previous.lastKnownAppState, activeWebViews: previous.activeWebViews, memory: getMemoryInfo(), expoUpdate: getExpoUpdateContext(), normalBackground: previous.normal_background, heartbeatAgeMs: Date.now() - Date.parse(previous.lastHeartbeatAt), }, }, fingerprint: ["app_lifecycle", "unexpected_exit", Platform.OS], }); await AsyncStorage.setItem( STORAGE_KEY, JSON.stringify({ ...previous, reportedUnexpectedExitForSessionStartedAt: previous.sessionStartedAt, }) ); }; export async function startAppLifecycleCrashSentinel(options: StartOptions) { const startedAt = nowIso(); const previous = safeJsonParse(await AsyncStorage.getItem(STORAGE_KEY)); if (previous && shouldReportUnexpectedExit(previous, Date.now())) { await reportUnexpectedExit(previous, options.nativeScreensMode); } currentState = { app_session_active: true, normal_background: false, sessionStartedAt: startedAt, lastHeartbeatAt: startedAt, lastKnownAppState: AppState.currentState ?? "unknown", lastRootTab: getRootTab(), previousRootNavigation: latestNavigationSnapshot, activeWebViews: Array.from(activeWebViews).sort(), nativeScreensMode: options.nativeScreensMode, }; Sentry.setContext("app_lifecycle_sentinel", { sessionStartedAt: currentState.sessionStartedAt, lastHeartbeatAt: currentState.lastHeartbeatAt, lastKnownAppState: currentState.lastKnownAppState, activeWebViews: currentState.activeWebViews, expoUpdate: getExpoUpdateContext(), }); await persistCurrentState(); if (heartbeatTimer) clearInterval(heartbeatTimer); heartbeatTimer = setInterval(() => { void persistCurrentState({ lastHeartbeatAt: nowIso(), lastKnownAppState: AppState.currentState ?? "unknown", normal_background: AppState.currentState === "background" ? true : currentState?.normal_background ?? false, }); }, HEARTBEAT_INTERVAL_MS); appStateSubscription?.remove(); appStateSubscription = AppState.addEventListener("change", (nextState) => { const patch: Partial = { lastKnownAppState: nextState, lastHeartbeatAt: nowIso(), }; if (nextState === "background") { patch.normal_background = true; } else if (nextState === "active") { patch.normal_background = false; } void persistCurrentState(patch); }); } export function stopAppLifecycleCrashSentinel() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } appStateSubscription?.remove(); appStateSubscription = null; } export function recordAppLifecycleRootNavigation(snapshot: Omit) { latestNavigationSnapshot = { ...snapshot, updatedAt: nowIso(), }; if (snapshot.rootRoute) { lastObservedRootRouteRef.current = snapshot.rootRoute; } void persistCurrentState(); } export function setAppLifecycleWebViewActive(name: string, active: boolean) { if (active) { activeWebViews.add(name); } else { activeWebViews.delete(name); } Sentry.setContext("active_webviews", { names: Array.from(activeWebViews).sort(), }); void persistCurrentState(); }