1import { AppActivityIndicator } from "../../components/AppActivityIndicator";
2import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
3import Constants from "expo-constants";
4import * as Updates from "expo-updates";
5import { useEffect, useState } from "react";
6import { Platform, View } from "react-native";
7import { useSafeAreaInsets } from "react-native-safe-area-context";
8
9import { SymbolView } from "../../components/AppSymbol";
10import { AppText as Text } from "../../components/AppText";
11import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
12import { SettingsActionRow } from "../settings/components/SettingsActionRow";
13import { SettingsScreen } from "../settings/components/SettingsScreen";
14import { SettingsSection } from "../settings/components/SettingsSection";
15import {
16 formatStartupCrashReport,
17 parseStartupCrashRecords,
18 type StartupCrashRecord,
19} from "./crash-log-model";
20
21// expo-updates keeps its persistent log this long. Reading any further back
22// returns nothing, so this is the whole available window.
23const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
24
25type CrashLogState =
26 | { readonly status: "loading" }
27 | { readonly status: "unavailable" }
28 | { readonly status: "ready"; readonly records: ReadonlyArray<StartupCrashRecord> };
29
30function appIdentity() {
31 return {
32 version: Constants.expoConfig?.version ?? "0.0.0",
33 build:
34 (Platform.OS === "ios"
35 ? Constants.platform?.ios?.buildNumber
36 : Constants.platform?.android?.versionCode?.toString()) ?? "dev",
37 };
38}
39
40/**
41 * Startup crashes that TestFlight and the stores strip from their reports.
42 * expo-updates' ErrorRecovery writes the JS error and component stack to its
43 * own log before aborting the process, so the next launch can show it here.
44 */
45export function SettingsDiagnosticsRouteScreen() {
46 const insets = useSafeAreaInsets();
47 const [state, setState] = useState<CrashLogState>(() =>
48 Updates.isEnabled ? { status: "loading" } : { status: "unavailable" },
49 );
50 const [copied, setCopied] = useState(false);
51
52 useEffect(() => {
53 if (!Updates.isEnabled) return;
54 let cancelled = false;
55 Updates.readLogEntriesAsync(LOG_WINDOW_MS)
56 .then((entries) => {
57 if (cancelled) return;
58 setState({ status: "ready", records: parseStartupCrashRecords(entries) });
59 })
60 .catch((error: unknown) => {
61 console.warn("[diagnostics] could not read the expo-updates log", error);
62 if (!cancelled) setState({ status: "unavailable" });
63 });
64 return () => {
65 cancelled = true;
66 };
67 }, []);
68
69 const records = state.status === "ready" ? state.records : [];
70 const copyReport = async () => {
71 const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), {
72 target: "crash report",
73 });
74 if (ok) setCopied(true);
75 };
76
77 return (
78 <SettingsScreen title="Diagnostics">
79 <ScrollView
80 contentInsetAdjustmentBehavior="automatic"
81 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
82 showsVerticalScrollIndicator={false}
83 className="flex-1"
84 contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]"
85 >
86 <SettingsSection title="Startup crashes">
87 {state.status === "loading" ? (
88 <View className="items-center gap-3 px-6 py-8">
89 <AppActivityIndicator />
90 <Text className="text-center text-sm text-foreground-muted">Reading crash log…</Text>
91 </View>
92 ) : state.status === "unavailable" ? (
93 <EmptyState
94 icon="exclamationmark.triangle"
95 title="Crash log unavailable"
96 detail="Startup crash records are only kept in store and TestFlight builds."
97 />
98 ) : records.length === 0 ? (
99 <EmptyState
100 icon="checkmark.circle"
101 title="No startup crashes"
102 detail="Nothing has taken the app down during launch in the last 7 days."
103 />
104 ) : (
105 records.map((record, index) => (
106 <CrashRow key={record.timestamp} record={record} first={index === 0} />
107 ))
108 )}
109 </SettingsSection>
110
111 <View className="gap-3">
112 <SettingsSection title="Actions">
113 <SettingsActionRow
114 icon={copied ? "checkmark" : "doc.on.doc"}
115 label={copied ? "Copied" : "Copy crash report"}
116 disabled={state.status !== "ready"}
117 onPress={() => void copyReport()}
118 />
119 </SettingsSection>
120 <Text className="px-2 text-sm leading-normal text-foreground-muted">
121 Paste the report into a GitHub issue. It contains the app version, the JavaScript error
122 message, and the component stack. Error messages can quote values from the app, so read
123 it over before sharing.
124 </Text>
125 </View>
126 </ScrollView>
127 </SettingsScreen>
128 );
129}
130
131function EmptyState(props: {
132 readonly icon: "exclamationmark.triangle" | "checkmark.circle";
133 readonly title: string;
134 readonly detail: string;
135}) {
136 return (
137 <View className="items-center gap-2 px-6 py-8">
138 <SymbolView
139 name={props.icon}
140 size={28}
141 tintColorClassName="accent-icon"
142 type="monochrome"
143 weight="regular"
144 />
145 <Text className="text-center text-base text-foreground">{props.title}</Text>
146 <Text className="text-center text-sm text-foreground-muted">{props.detail}</Text>
147 </View>
148 );
149}
150
151function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) {
152 const { record } = props;
153 return (
154 <View className={props.first ? "gap-1.5 p-4" : "gap-1.5 border-t border-border-subtle p-4"}>
155 <Text className="text-xs text-foreground-muted">
156 {new Date(record.timestamp).toLocaleString()}
157 </Text>
158 <Text selectable className="text-base leading-snug text-danger-foreground">
159 {record.description}
160 </Text>
161 {record.frames.length > 0 ? (
162 <Text selectable className="font-mono text-xs leading-snug text-foreground-muted">
163 {record.frames.slice(0, 4).join("\n")}
164 </Text>
165 ) : null}
166 </View>
167 );
168}