diff --git a/apps/mobile/app.json b/apps/mobile/app.json index fa2b758..dd6be70 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -7,12 +7,6 @@ "icon": "./assets/icon.png", "scheme": "app.sauci", "userInterfaceStyle": "automatic", - "newArchEnabled": true, - "splash": { - "image": "./assets/splash-icon.png", - "resizeMode": "contain", - "backgroundColor": "#1a1a2e" - }, "ios": { "supportsTablet": false, "bundleIdentifier": "com.sauci.app", @@ -223,6 +217,14 @@ }, "plugins": [ "./plugins/withFixHoisting.js", + [ + "expo-splash-screen", + { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#1a1a2e" + } + ], "@react-native-firebase/app", [ "expo-build-properties", @@ -320,7 +322,10 @@ "origin": false }, "eas": { - "projectId": "807ca0af-e9bd-40e8-a658-f3382072c724" + "projectId": "807ca0af-e9bd-40e8-a658-f3382072c724", + "observe": { + "endpointUrl": "https://ota.apps.jbrunton.co.uk/observe/9a66f3c2-fda7-416f-87e2-6db833b32e9d" + } } }, "owner": "jjbrunton-apps" diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 0e4efe1..fa47adc 100755 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -5,6 +5,11 @@ import "../src/polyfills/web"; import { initCrashlytics, setUserContext, clearUserContext } from "../src/lib/crashlytics"; initCrashlytics(); +// Install the Observe global JS error handler after Crashlytics so it wraps +// (and always calls) whatever handler is already registered. +import { installObserveGlobalErrorHandler, logAppStarted } from "../src/lib/observe"; +installObserveGlobalErrorHandler(); + // Analytics imports (initialization deferred until app mounts) import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics"; @@ -73,6 +78,7 @@ export default function RootLayout() { analyticsInitialized.current = true; initAnalytics(); Events.appOpened("cold"); + logAppStarted(); } }, []); @@ -115,15 +121,15 @@ export default function RootLayout() { // Fetch user on mount fetchUser(); - // Handle initial URL if app was opened via deep link - const handleDeepLink = async (url: string | null) => { - if (!url) return; + // Handle initial URL if app was opened via deep link + const handleDeepLink = async (url: string | null) => { + if (!url) return; // Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code}) // are handled separately from the Supabase auth deep link flow below. - const inviteLink = parseInviteLinkCode(url); - if (inviteLink) { - Events.inviteLinkOpened(inviteLink.source); + const inviteLink = parseInviteLinkCode(url); + if (inviteLink) { + Events.inviteLinkOpened(inviteLink.source); await stashPendingInviteCode(inviteLink.code); const { isAuthenticated, user } = useAuthStore.getState(); @@ -200,15 +206,15 @@ export default function RootLayout() { } }; - // Get initial URL - Linking.getInitialURL().then((url) => { - handleDeepLink(url); - }); + // Get initial URL + Linking.getInitialURL().then((url) => { + handleDeepLink(url); + }); - // Listen for new deep links while app is open - const linkingSubscription = Linking.addEventListener("url", (event) => { - handleDeepLink(event.url); - }); + // Listen for new deep links while app is open + const linkingSubscription = Linking.addEventListener("url", (event) => { + handleDeepLink(event.url); + }); // Set up push notification response listener (for taps while app is running) const notificationSubscription = setupNotificationResponseListener(); diff --git a/apps/mobile/src/__tests__/lib/observe.test.ts b/apps/mobile/src/__tests__/lib/observe.test.ts new file mode 100644 index 0000000..80db114 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/observe.test.ts @@ -0,0 +1,99 @@ +jest.mock('expo-observe', () => ({ + Observe: { + logEvent: jest.fn(), + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { Observe } = require('expo-observe'); +const logEventMock = Observe.logEvent as jest.Mock; + +type ErrorHandler = (error: unknown, isFatal: boolean) => void; + +function withFreshObserveModule(run: (mod: typeof import('@/lib/observe')) => T): T { + let result!: T; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('@/lib/observe') as typeof import('@/lib/observe'); + result = run(mod); + }); + return result; +} + +function installFakeErrorUtils(previousHandler: jest.Mock) { + let installedHandler: ErrorHandler = () => undefined; + const globalWithErrorUtils = global as unknown as { + ErrorUtils: { getGlobalHandler: () => unknown; setGlobalHandler: (fn: unknown) => void }; + }; + globalWithErrorUtils.ErrorUtils = { + getGlobalHandler: () => previousHandler, + setGlobalHandler: (fn) => { + installedHandler = fn as ErrorHandler; + }, + }; + return () => installedHandler; +} + +describe('observe', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('logs app_started only once per module instance', () => { + withFreshObserveModule(({ logAppStarted }) => { + logAppStarted(); + logAppStarted(); + }); + + expect(logEventMock).toHaveBeenCalledTimes(1); + expect(logEventMock).toHaveBeenCalledWith('app_started'); + }); + + it('does not throw when Observe.logEvent fails', () => { + logEventMock.mockImplementationOnce(() => { + throw new Error('native module unavailable'); + }); + + withFreshObserveModule(({ logAppStarted }) => { + expect(() => logAppStarted()).not.toThrow(); + }); + }); + + it('wraps the previous ErrorUtils handler, logging xprem_js_crash and delegating', () => { + const previousHandler = jest.fn(); + const getInstalledHandler = installFakeErrorUtils(previousHandler); + + withFreshObserveModule(({ installObserveGlobalErrorHandler }) => { + installObserveGlobalErrorHandler(); + }); + + const error = new Error('boom'); + getInstalledHandler()(error, true); + + expect(logEventMock).toHaveBeenCalledWith( + 'xprem_js_crash', + expect.objectContaining({ + severity: 'fatal', + body: 'boom', + attributes: { isFatal: true }, + }) + ); + expect(previousHandler).toHaveBeenCalledWith(error, true); + }); + + it('still calls the previous handler when Observe.logEvent throws', () => { + const previousHandler = jest.fn(); + const getInstalledHandler = installFakeErrorUtils(previousHandler); + logEventMock.mockImplementationOnce(() => { + throw new Error('native module unavailable'); + }); + + withFreshObserveModule(({ installObserveGlobalErrorHandler }) => { + installObserveGlobalErrorHandler(); + }); + + getInstalledHandler()(new Error('boom'), false); + + expect(previousHandler).toHaveBeenCalledWith(expect.any(Error), false); + }); +}); diff --git a/apps/mobile/src/lib/observe.ts b/apps/mobile/src/lib/observe.ts new file mode 100644 index 0000000..cf4389c --- /dev/null +++ b/apps/mobile/src/lib/observe.ts @@ -0,0 +1,66 @@ +// EAS Observe wrapper. Keeps expo-observe usage in one place and fails soft +// so a metrics problem never blocks app startup or crash reporting. +import { Observe } from "expo-observe"; + +type ErrorHandler = (error: unknown, isFatal: boolean) => void; + +interface GlobalWithErrorUtils { + ErrorUtils?: { + getGlobalHandler(): ErrorHandler; + setGlobalHandler(handler: ErrorHandler): void; + }; +} + +let appStartedLogged = false; + +/** + * Logs the `app_started` custom event once per process lifetime. Safe to call + * more than once; only the first call has an effect. + */ +export function logAppStarted() { + if (appStartedLogged) return; + appStartedLogged = true; + + try { + Observe.logEvent("app_started"); + } catch (error) { + console.warn("[Observe] Failed to log app_started:", error); + } +} + +let globalErrorHandlerInstalled = false; + +/** + * Installs a global fatal JS error handler that records an `xprem_js_crash` + * Observe event and then always delegates to whatever handler was previously + * registered (RN's default redbox handler, or another handler such as + * Crashlytics's, if one is installed first). Existing crash reporting is + * never replaced, only observed. + */ +export function installObserveGlobalErrorHandler() { + if (globalErrorHandlerInstalled) return; + + const errorUtils = (global as GlobalWithErrorUtils).ErrorUtils; + if (!errorUtils) { + console.warn("[Observe] ErrorUtils is unavailable; xprem_js_crash will not be recorded."); + return; + } + + globalErrorHandlerInstalled = true; + const previousHandler = errorUtils.getGlobalHandler(); + + errorUtils.setGlobalHandler((error, isFatal) => { + try { + const message = error instanceof Error ? error.message : String(error); + Observe.logEvent("xprem_js_crash", { + severity: "fatal", + body: message, + attributes: { isFatal: Boolean(isFatal) }, + }); + } catch (observeError) { + console.warn("[Observe] Failed to log xprem_js_crash:", observeError); + } + + previousHandler(error, isFatal); + }); +}