diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ee8cae30d5ae..049cd2888962 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; +import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; @@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsDiagnostics: createNativeStackScreen({ + screen: SettingsDiagnosticsRouteScreen, + linking: "diagnostics", + options: { + title: "Diagnostics", + }, + }), SettingsUsageAccount: createNativeStackScreen({ screen: UsageLimitAccountScreen, options: { title: "Account" }, diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 4da3a97c2bf6..e5d0137ee406 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; import IconSparkles from "@tabler/icons-react-native/IconSparkles"; import IconStack2 from "@tabler/icons-react-native/IconStack2"; +import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; import IconSun from "@tabler/icons-react-native/IconSun"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; @@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, safari: IconExternalLink, "server.rack": IconServer, + stethoscope: IconStethoscope, "sidebar.left": IconLayoutSidebar, "sidebar.right": IconLayoutSidebarRight, "slider.horizontal.3": IconAdjustmentsHorizontal, diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx new file mode 100644 index 000000000000..a55bb8f859e6 --- /dev/null +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -0,0 +1,175 @@ +import Constants from "expo-constants"; +import * as Updates from "expo-updates"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { + formatStartupCrashReport, + parseStartupCrashRecords, + type StartupCrashRecord, +} from "./crash-log-model"; + +// expo-updates keeps its persistent log this long. Reading any further back +// returns nothing, so this is the whole available window. +const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +type CrashLogState = + | { readonly status: "loading" } + | { readonly status: "unavailable" } + | { readonly status: "ready"; readonly records: ReadonlyArray }; + +function appIdentity() { + return { + version: Constants.expoConfig?.version ?? "0.0.0", + build: + (Platform.OS === "ios" + ? Constants.platform?.ios?.buildNumber + : Constants.platform?.android?.versionCode?.toString()) ?? "dev", + }; +} + +/** + * Startup crashes that TestFlight and the stores strip from their reports. + * expo-updates' ErrorRecovery writes the JS error and component stack to its + * own log before aborting the process, so the next launch can show it here. + */ +export function SettingsDiagnosticsRouteScreen() { + const insets = useSafeAreaInsets(); + const [state, setState] = useState(() => + Updates.isEnabled ? { status: "loading" } : { status: "unavailable" }, + ); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!Updates.isEnabled) return; + let cancelled = false; + Updates.readLogEntriesAsync(LOG_WINDOW_MS) + .then((entries) => { + if (cancelled) return; + setState({ status: "ready", records: parseStartupCrashRecords(entries) }); + }) + .catch((error: unknown) => { + console.warn("[diagnostics] could not read the expo-updates log", error); + if (!cancelled) setState({ status: "unavailable" }); + }); + return () => { + cancelled = true; + }; + }, []); + + const records = state.status === "ready" ? state.records : []; + const copyReport = async () => { + const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), { + target: "crash report", + }); + if (ok) setCopied(true); + }; + + return ( + + + + {state.status === "loading" ? ( + + + Reading crash log… + + ) : state.status === "unavailable" ? ( + + ) : records.length === 0 ? ( + + ) : ( + records.map((record, index) => ( + + )) + )} + + + + + void copyReport()} + className="flex-row items-center gap-4 p-4 disabled:opacity-40" + > + + + {copied ? "Copied" : "Copy crash report"} + + + + + Paste the report into a GitHub issue. It contains the app version, the JavaScript error + message, and the component stack. Error messages can quote values from the app, so read + it over before sharing. + + + + + ); +} + +function EmptyState(props: { + readonly icon: "exclamationmark.triangle" | "checkmark.circle"; + readonly title: string; + readonly detail: string; +}) { + return ( + + + {props.title} + {props.detail} + + ); +} + +function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) { + const { record } = props; + return ( + + + {new Date(record.timestamp).toLocaleString()} + + + {record.description} + + {record.frames.length > 0 ? ( + + {record.frames.slice(0, 4).join("\n")} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/diagnostics/crash-log-model.test.ts b/apps/mobile/src/features/diagnostics/crash-log-model.test.ts new file mode 100644 index 000000000000..761d9a49ed96 --- /dev/null +++ b/apps/mobile/src/features/diagnostics/crash-log-model.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model"; + +// Verbatim shape of the entry expo-updates wrote for the build 56 launch crash. +const BUNDLE = + "/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle"; +const FATAL = { + timestamp: 1789277752000, + level: "error", + code: "JSRuntimeError", + message: [ + "ErrorRecovery fatal exception: Fatal error: Time: 1789277752033.127930", + "Domain: RCTErrorDomain", + "Code: 0", + "Description: Unhandled JS Exception: TypeError: Cannot read property 'defaultModelSelection' of null", + "", + "This error is located at:", + ` at NewTaskFlowProvider (${BUNDLE}:590585:51)`, + ` at NavigationProvider (${BUNDLE}:139067:3)`, + " at RNSSafeAreaView ()", + ].join("\n"), +}; + +describe("parseStartupCrashRecords", () => { + it("extracts the JS error and a compact component stack from a fatal entry", () => { + const [record] = parseStartupCrashRecords([FATAL]); + expect(record?.description).toBe( + "TypeError: Cannot read property 'defaultModelSelection' of null", + ); + expect(record?.frames).toEqual([ + "NewTaskFlowProvider (main.jsbundle:590585:51)", + "NavigationProvider (main.jsbundle:139067:3)", + "RNSSafeAreaView", + ]); + expect(record?.detail).toContain("Domain: RCTErrorDomain"); + }); + + it("ignores update-check noise and orders crashes newest first", () => { + const records = parseStartupCrashRecords([ + { timestamp: 1, level: "info", code: "NoUpdatesAvailable", message: "checked" }, + { ...FATAL, timestamp: 10 }, + { timestamp: 5, level: "error", code: "UpdateFailedToLoad", message: "nope" }, + { ...FATAL, timestamp: 20 }, + ]); + expect(records.map((record) => record.timestamp)).toEqual([20, 10]); + }); + + it("skips a runtime error entry without a description line", () => { + expect( + parseStartupCrashRecords([ + { ...FATAL, message: "ErrorRecovery fatal exception: Fatal exception: Name: x" }, + ]), + ).toEqual([]); + }); +}); + +describe("formatStartupCrashReport", () => { + it("writes the app version and each crash's full detail", () => { + const report = formatStartupCrashReport(parseStartupCrashRecords([FATAL]), { + version: "1.1.1", + build: "56", + }); + expect(report.startsWith("T3 Code 1.1.1 (56)\n")).toBe(true); + expect(report).toContain("2026-09-13T05:35:52.000Z"); + expect(report).toContain("at NewTaskFlowProvider"); + }); + + it("says so when nothing was recorded", () => { + expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe( + "T3 Code 1.1.1 (56)\nNo startup crashes recorded.", + ); + }); +}); diff --git a/apps/mobile/src/features/diagnostics/crash-log-model.ts b/apps/mobile/src/features/diagnostics/crash-log-model.ts new file mode 100644 index 000000000000..4c222a1f0be9 --- /dev/null +++ b/apps/mobile/src/features/diagnostics/crash-log-model.ts @@ -0,0 +1,83 @@ +/** + * The shape of an expo-updates log entry we care about. Mirrors + * `UpdatesLogEntry` structurally so the model needs no native module at test + * time. + */ +export interface UpdatesLogEntryLike { + readonly timestamp: number; + readonly message: string; + readonly code: string; + readonly level: string; +} + +/** One fatal JavaScript error that took the app down at startup. */ +export interface StartupCrashRecord { + readonly timestamp: number; + /** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */ + readonly description: string; + /** Component stack (`at Name (bundle:line:col)`), one frame per entry. */ + readonly frames: ReadonlyArray; + /** Everything after the message header, verbatim, for copying. */ + readonly detail: string; +} + +const FATAL_PREFIX = "ErrorRecovery fatal exception: "; +const DESCRIPTION_PREFIX = "Description: "; +const UNHANDLED_PREFIX = "Unhandled JS Exception: "; + +/** + * Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while + * aborting the process. Everything else in that log (update checks, asset + * loads) is noise for a "why did the app crash" question. + */ +export function parseStartupCrashRecords( + entries: ReadonlyArray, +): ReadonlyArray { + const records: StartupCrashRecord[] = []; + for (const entry of entries) { + if (entry.code !== "JSRuntimeError" || !entry.message.startsWith(FATAL_PREFIX)) continue; + const body = entry.message.slice(FATAL_PREFIX.length); + const lines = body.split("\n"); + const descriptionLine = lines.find((line) => line.startsWith(DESCRIPTION_PREFIX)); + if (descriptionLine === undefined) continue; + let description = descriptionLine.slice(DESCRIPTION_PREFIX.length).trim(); + if (description.startsWith(UNHANDLED_PREFIX)) { + description = description.slice(UNHANDLED_PREFIX.length); + } + const frames = lines + .map((line) => line.trim()) + .filter((line) => line.startsWith("at ")) + .map(compactFrame); + records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() }); + } + // Newest first; the log appends in time order. + return records.sort((left, right) => right.timestamp - left.timestamp); +} + +/** + * `at NewTaskFlowProvider (/Users/expo/…/main.jsbundle:590585:51)` reads as + * `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is + * the same on every frame and says nothing. + */ +function compactFrame(line: string): string { + const match = /^at (.+?) \((.*)\)$/.exec(line); + if (!match) return line.slice("at ".length); + const [, name = "", location = ""] = match; + const file = location.slice(location.lastIndexOf("/") + 1); + return location === "" ? name : `${name} (${file})`; +} + +/** The report a user pastes into an issue: every recorded crash, newest first. */ +export function formatStartupCrashReport( + records: ReadonlyArray, + app: { readonly version: string; readonly build: string }, +): string { + const header = `T3 Code ${app.version} (${app.build})`; + if (records.length === 0) return `${header}\nNo startup crashes recorded.`; + return [ + header, + ...records.map( + (record) => `\n--- ${new Date(record.timestamp).toISOString()} ---\n${record.detail}`, + ), + ].join("\n"); +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b4614d1c071c..d343f2dc8830 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -821,6 +821,7 @@ function AppSettingsSection() { return ( +