1import { AppActivityIndicator } from "../../components/ui/AppActivityIndicator";
2import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
3import * as Application from "expo-application";
4import Constants from "expo-constants";
5import * as Updates from "expo-updates";
6import { useAtomSet, useAtomValue } from "@effect/atom-react";
7import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";
9import * as Schema from "effect/Schema";
10import { AsyncResult, Atom } from "effect/reactivity";
11import { useState } from "react";
12import { View } from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";
14
15import { SymbolView } from "../../components/ui/AppSymbol";
16import { AppText as Text } from "../../components/ui/AppText";
17import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
18import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
19import { crashReportingAvailable, setCrashReportsEnabled } from "../observability/observe";
20import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow";
21import { SettingsScreen } from "../settings/components/SettingsScreen";
22import { SettingsSection } from "../settings/components/SettingsSection";
23import { SettingsSwitchRow } from "../settings/components/SettingsSwitchRow";
24import {
25 formatStartupCrashReport,
26 parseStartupCrashRecords,
27 type StartupCrashRecord,
28} from "./crash-log-model";
29
30// expo-updates retains only seven days of logs.
31const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
32
33type CrashLogState =
34 | { readonly status: "loading" }
35 | { readonly status: "unavailable" }
36 | { readonly status: "ready"; readonly records: ReadonlyArray<StartupCrashRecord> };
37
38class CrashLogReadError extends Schema.TaggedError<CrashLogReadError>()("CrashLogReadError", {
39 cause: Schema.Defect(),
40}) {
41 override get message(): string {
42 return "Could not read the expo-updates log.";
43 }
44}
45
46// Not kept alive: each visit to the screen re-reads the log.
47const crashLogAtom = Atom.make(
48 Effect.tryPromise({
49 try: () => Updates.readLogEntriesAsync(LOG_WINDOW_MS),
50 catch: (cause) => new CrashLogReadError({ cause }),
51 }).pipe(
52 Effect.map((entries): CrashLogState => ({
53 status: "ready",
54 records: parseStartupCrashRecords(entries),
55 })),
56 Effect.catch((error) =>
57 Effect.logWarning("[diagnostics] could not read the expo-updates log", error).pipe(
58 Effect.as<CrashLogState>({ status: "unavailable" }),
59 ),
60 ),
61 ),
62).pipe(Atom.withLabel("mobile:diagnostics-crash-log"));
63
64const UNAVAILABLE_CRASH_LOG_ATOM = Atom.make(
65 AsyncResult.success<CrashLogState>({ status: "unavailable" }),
66);
67
68function useCrashLogState(): CrashLogState {
69 const result = useAtomValue(Updates.isEnabled ? crashLogAtom : UNAVAILABLE_CRASH_LOG_ATOM);
70 return AsyncResult.isSuccess(result) ? result.value : { status: "loading" };
71}
72
73function appIdentity() {
74 return {
75 version: Constants.expoConfig?.version ?? "0.0.0",
76 // EAS assigns build numbers remotely, so expo-constants never sees them;
77 // only the installed binary knows its own.
78 build: Application.nativeBuildVersion ?? "dev",
79 };
80}
81
82export function SettingsDiagnosticsRouteScreen() {
83 const insets = useSafeAreaInsets();
84 const state = useCrashLogState();
85 const [copied, setCopied] = useState(false);
86
87 const records = state.status === "ready" ? state.records : [];
88 const copyReport = async () => {
89 const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), {
90 target: "crash report",
91 });
92 if (ok) setCopied(true);
93 };
94
95 return (
96 <SettingsScreen title="Diagnostics">
97 <ScrollView
98 contentInsetAdjustmentBehavior="automatic"
99 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
100 showsVerticalScrollIndicator={false}
101 className="flex-1"
102 contentContainerClassName="gap-6 px-5 pt-4 pb-[18px]"
103 >
104 {crashReportingAvailable ? <CrashReportsSetting /> : null}
105
106 <SettingsSection title="Startup crashes">
107 {state.status === "loading" ? (
108 <View className="items-center gap-3 px-6 py-8">
109 <AppActivityIndicator />
110 <Text className="text-center text-sm text-foreground-muted">Reading crash log…</Text>
111 </View>
112 ) : state.status === "unavailable" ? (
113 <EmptyState
114 icon="exclamationmark.triangle"
115 title="Crash log unavailable"
116 detail="Startup crash records are only kept in store and TestFlight builds."
117 />
118 ) : records.length === 0 ? (
119 <EmptyState
120 icon="checkmark.circle"
121 title="No startup crashes"
122 detail="Nothing has taken the app down during launch in the last 7 days."
123 />
124 ) : (
125 records.map((record, index) => (
126 <CrashRow key={record.timestamp} record={record} first={index === 0} />
127 ))
128 )}
129 </SettingsSection>
130
131 <View className="gap-3">
132 <SettingsSection title="Actions">
133 <SettingsActionRow
134 icon={copied ? "checkmark" : "doc.on.doc"}
135 label={copied ? "Copied" : "Copy crash report"}
136 disabled={state.status !== "ready"}
137 onPress={() => void copyReport()}
138 />
139 </SettingsSection>
140 <Text className="px-2 text-sm leading-normal text-foreground-muted">
141 Paste the report into a GitHub issue. It contains the app version, the JavaScript error
142 message, and the component stack. Error messages can quote values from the app, so read
143 it over before sharing.
144 </Text>
145 </View>
146 </ScrollView>
147 </SettingsScreen>
148 );
149}
150
151function CrashReportsSetting() {
152 const preferences = useAtomValue(mobilePreferencesAtom);
153 const savePreferences = useAtomSet(updateMobilePreferencesAtom, { mode: "promiseExit" });
154 // One change at a time, so a slow save never turns sending back on after a later opt-out.
155 const [saving, setSaving] = useState(false);
156 const ready = AsyncResult.isSuccess(preferences) && !preferences.waiting;
157 const enabled = AsyncResult.isSuccess(preferences)
158 ? preferences.value.crashReportsEnabled !== false
159 : true;
160
161 const change = (value: boolean) => {
162 setSaving(true);
163 // Sending stops before the save and starts only after it.
164 if (!value) void setCrashReportsEnabled(false);
165 savePreferences({ crashReportsEnabled: value })
166 .then((exit) => {
167 if (Exit.isFailure(exit)) {
168 console.warn("[diagnostics] could not save the crash report setting", exit.cause);
169 return value ? undefined : setCrashReportsEnabled(true);
170 }
171 return value ? setCrashReportsEnabled(true) : undefined;
172 })
173 .catch((error: unknown) => {
174 console.warn("[diagnostics] could not apply the crash report setting", error);
175 })
176 .finally(() => setSaving(false));
177 };
178
179 return (
180 <View className="gap-3">
181 <SettingsSection title="Crash reports">
182 <SettingsSwitchRow
183 icon="tray.and.arrow.up"
184 label="Send crash reports"
185 disabled={!ready || saving}
186 value={enabled}
187 onValueChange={change}
188 />
189 </SettingsSection>
190 <Text className="px-2 text-sm leading-normal text-foreground-muted">
191 When the app crashes or hits an error, it sends the error message, the stack trace, the app
192 version, and the device model and OS version to the T3 Code team. Each launch also sends
193 startup timings. Reports carry a random ID for this install.
194 </Text>
195 </View>
196 );
197}
198
199function EmptyState(props: {
200 readonly icon: "exclamationmark.triangle" | "checkmark.circle";
201 readonly title: string;
202 readonly detail: string;
203}) {
204 return (
205 <View className="items-center gap-2 px-6 py-8">
206 <SymbolView
207 name={props.icon}
208 size={28}
209 tintColorClassName="accent-icon"
210 type="monochrome"
211 weight="regular"
212 />
213 <Text className="text-center text-base text-foreground">{props.title}</Text>
214 <Text className="text-center text-sm text-foreground-muted">{props.detail}</Text>
215 </View>
216 );
217}
218
219function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) {
220 const { record } = props;
221 return (
222 <View className={props.first ? "gap-1.5 p-4" : "gap-1.5 border-t border-border-subtle p-4"}>
223 <Text className="text-xs text-foreground-muted">
224 {new Date(record.timestamp).toLocaleString()}
225 </Text>
226 <Text selectable className="text-base leading-snug text-danger-foreground">
227 {record.description}
228 </Text>
229 {record.frames.length > 0 ? (
230 <Text selectable className="font-mono text-xs leading-snug text-foreground-muted">
231 {record.frames.slice(0, 4).join("\n")}
232 </Text>
233 ) : null}
234 </View>
235 );
236}