diff --git a/apps/bare-expo/ios/Podfile.lock b/apps/bare-expo/ios/Podfile.lock index 4d2dd6f8124e44..dd5b21ac9ab29e 100644 --- a/apps/bare-expo/ios/Podfile.lock +++ b/apps/bare-expo/ios/Podfile.lock @@ -633,6 +633,8 @@ PODS: - ExpoModulesCore - ExpoSQLite (57.0.1): - ExpoModulesCore + - ExpoSQLite/Tests (57.0.1): + - ExpoModulesCore - ExpoStoreReview (57.0.1): - ExpoModulesCore - ExpoSymbols (57.0.1): @@ -3425,6 +3427,7 @@ DEPENDENCIES: - ExpoSpeech (from `../../../packages/expo-speech/ios`) - ExpoSplashScreen (from `../../../packages/expo-splash-screen/ios`) - ExpoSQLite (from `../../../packages/expo-sqlite/ios`) + - ExpoSQLite/Tests (from `../../../packages/expo-sqlite/ios`) - ExpoStoreReview (from `../../../packages/expo-store-review/ios`) - ExpoSymbols (from `../../../packages/expo-symbols/ios`) - ExpoSystemUI (from `../../../packages/expo-system-ui/ios`) @@ -4092,7 +4095,7 @@ SPEC CHECKSUMS: ExpoSMS: ac35f6c85b72ee5b18a7c6bb06550fbd4a683775 ExpoSpeech: 0e90904e2af5d6f166d4d2ffbdab535686597938 ExpoSplashScreen: 247464c0fe484f766892db0d66c3fe54f92a624e - ExpoSQLite: 7ea4f5b0ebd0b6f15927c2349db7624a5abf6beb + ExpoSQLite: 73ec3c3dd0c5e8c6592c341edf00efa6271c51a8 ExpoStoreReview: 2a0f6b8e04112aea2f6e3e1cb84109d78f4d041f ExpoSymbols: 7c7c7bd3c52f0b6dcbba6e8af4088b0dc1ea7d29 ExpoSystemUI: ff3142b323ae7b4d11dc0b1dc4acfa30d92be7dc diff --git a/apps/native-component-list/src/screens/Audio/AudioControlsScreen.tsx b/apps/native-component-list/src/screens/Audio/AudioControlsScreen.tsx index 40a25fd163593e..6e2f7e6411deb5 100644 --- a/apps/native-component-list/src/screens/Audio/AudioControlsScreen.tsx +++ b/apps/native-component-list/src/screens/Audio/AudioControlsScreen.tsx @@ -40,7 +40,7 @@ export default function AudioControlsScreen(props: any) { React.useLayoutEffect(() => { AudioModule.setAudioModeAsync({ shouldPlayInBackground: true, - interruptionMode: 'doNotMix', + interruptionMode: 'doNotMixPersistent', playsInSilentMode: true, allowsRecording: false, }).catch((error: unknown) => diff --git a/apps/native-component-list/src/screens/Audio/AudioModeSelector.android.tsx b/apps/native-component-list/src/screens/Audio/AudioModeSelector.android.tsx index e3a1acef1992cc..58e702c5a53828 100644 --- a/apps/native-component-list/src/screens/Audio/AudioModeSelector.android.tsx +++ b/apps/native-component-list/src/screens/Audio/AudioModeSelector.android.tsx @@ -117,6 +117,10 @@ export default function AudioModeSelector() { title: 'Do not mix', value: 'doNotMix', })} + {renderModeSelector({ + title: 'Do not mix (persistent)', + value: 'doNotMixPersistent', + })} {renderModeSelector({ title: 'Duck others', value: 'duckOthers', diff --git a/apps/native-component-list/src/screens/Audio/AudioModeSelector.ios.tsx b/apps/native-component-list/src/screens/Audio/AudioModeSelector.ios.tsx index f21ec1f8d9fda8..d7bd7d6bc8a61e 100644 --- a/apps/native-component-list/src/screens/Audio/AudioModeSelector.ios.tsx +++ b/apps/native-component-list/src/screens/Audio/AudioModeSelector.ios.tsx @@ -130,6 +130,10 @@ export default function AudioModeSelector() { title: 'Do not mix', value: 'doNotMix', })} + {renderModeSelector({ + title: 'Do not mix (persistent)', + value: 'doNotMixPersistent', + })} {renderModeSelector({ disabled: state.next.playsInSilentMode === false, title: 'Duck others', diff --git a/apps/native-component-list/src/screens/Audio/AudioPlaylistScreen.tsx b/apps/native-component-list/src/screens/Audio/AudioPlaylistScreen.tsx index 175b629d632be4..c0b4c1b2887bcd 100644 --- a/apps/native-component-list/src/screens/Audio/AudioPlaylistScreen.tsx +++ b/apps/native-component-list/src/screens/Audio/AudioPlaylistScreen.tsx @@ -91,7 +91,7 @@ export default function AudioPlaylistScreen() { useEffect(() => { setAudioModeAsync({ shouldPlayInBackground: true, - interruptionMode: 'doNotMix', + interruptionMode: 'doNotMixPersistent', playsInSilentMode: true, allowsRecording: false, }).catch((error: unknown) => diff --git a/apps/observe-tester/app/(tabs)/examples/expo-image/too-big.tsx b/apps/observe-tester/app/(tabs)/examples/expo-image/too-big.tsx index fd29e45b719761..5a70849fb53f40 100644 --- a/apps/observe-tester/app/(tabs)/examples/expo-image/too-big.tsx +++ b/apps/observe-tester/app/(tabs)/examples/expo-image/too-big.tsx @@ -8,8 +8,12 @@ import { useTheme } from '@/utils/theme'; // area against the screen budget (screen point area × pixel ratio² × 1.5) and logs an // `expo-image.oversized` warning because it is far beyond what any full-screen image needs. Use // `maxWidth`/`maxHeight` (see the "Correctly sized" page) to avoid this. +// The fake signing params exercise URL sanitization: the logged event reports the URL without the +// query and fragment (with `urlSanitized: true`) unless the integration is configured with +// `includeUrlParams: true`. Picsum ignores the unknown params, so the image still loads. const SIZE = 220; -const SOURCE = 'https://picsum.photos/seed/expo-image-too-big/4000/4000'; +const SOURCE = + 'https://picsum.photos/seed/expo-image-too-big/4000/4000?token=super-secret&sig=deadbeef#preview'; export default function TooBigImage() { const theme = useTheme(); @@ -27,7 +31,9 @@ export default function TooBigImage() { A 4000×4000px image loaded with no size constraints. Decoded:{' '} {image ? `${image.width * image.scale}×${image.height * image.scale}px` : '…'} — far beyond this device's screen ({Math.round(screen.width)}×{Math.round(screen.height)}pt @ - {PixelRatio.get()}x), so an `expo-image.oversized` warning is logged. Check the Sessions + {PixelRatio.get()}x), so an `expo-image.oversized` warning is logged. The source URL + carries fake signing params; the event reports it without them and with `urlSanitized: + true`, unless `includeUrlParams` is enabled in the integration config. Check the Sessions tab. {failed ? ( diff --git a/apps/observe-tester/app/_layout.tsx b/apps/observe-tester/app/_layout.tsx index 29082a9b3709d3..0bf63b16be42de 100644 --- a/apps/observe-tester/app/_layout.tsx +++ b/apps/observe-tester/app/_layout.tsx @@ -11,6 +11,7 @@ Observe.configure({ 'expo-router': { filteredParams: ['accountId', 'firstName'] }, 'expo-image': { oversizeThreshold: 1.5, + includeUrlParams: false, }, }, }); diff --git a/apps/router-e2e/app.config.js b/apps/router-e2e/app.config.js index e31d4bb56ddbe4..4f187761a1b5c1 100644 --- a/apps/router-e2e/app.config.js +++ b/apps/router-e2e/app.config.js @@ -26,7 +26,7 @@ module.exports = { package: 'dev.expo.routere2e', }, // For testing the output bundle - jsEngine: process.env.E2E_ROUTER_JS_ENGINE ?? (process.env.E2E_ROUTER_SRC ? 'jsc' : 'hermes'), + jsEngine: 'hermes', newArchEnabled: true, experiments: { noxcturnalTransformWorker: true, diff --git a/apps/router-e2e/package.json b/apps/router-e2e/package.json index 7a80d1072cfb6e..d33f9b6a248dcc 100644 --- a/apps/router-e2e/package.json +++ b/apps/router-e2e/package.json @@ -5,14 +5,14 @@ "main": "expo-router/entry", "scripts": { "prebuild": "TEMPLATE_DIR=\"../../templates/expo-template-bare-minimum\" && (cd \"$TEMPLATE_DIR\" && PACKED_FILE=$(npm pack) && mv \"$PACKED_FILE\" pack.tgz) && npx expo prebuild --clean --template \"$TEMPLATE_DIR/pack.tgz\"", - "start:01-rsc": "E2E_RSC_ENABLED=1 E2E_ROUTER_JS_ENGINE=hermes EXPO_USE_STATIC=server E2E_ROUTER_SRC=01-rsc expo start", - "export:01-rsc": "E2E_RSC_ENABLED=1 E2E_ROUTER_JS_ENGINE=hermes EXPO_USE_STATIC=server E2E_ROUTER_SRC=01-rsc expo export", - "start:02-server-actions": "E2E_SERVER_FUNCTIONS=1 E2E_RSC_ENABLED=1 E2E_ROUTER_JS_ENGINE=hermes EXPO_USE_STATIC=server E2E_ROUTER_SRC=02-server-actions expo start", - "export:02-server-actions": "E2E_SERVER_FUNCTIONS=1 E2E_RSC_ENABLED=1 E2E_ROUTER_JS_ENGINE=hermes EXPO_USE_STATIC=server E2E_ROUTER_SRC=02-server-actions expo export", - "start:03-server-actions-only": "E2E_SERVER_FUNCTIONS=1 E2E_ROUTER_JS_ENGINE=hermes EXPO_USE_STATIC=single E2E_ROUTER_SRC=03-server-actions-only expo start", - "export:03-server-actions-only": "E2E_SERVER_FUNCTIONS=1 E2E_ROUTER_JS_ENGINE=hermes EXPO_USE_STATIC=single E2E_ROUTER_SRC=03-server-actions-only expo export", - "start:04-server-error-boundaries": "E2E_SERVER_FUNCTIONS=1 E2E_ROUTER_JS_ENGINE=hermes EXPO_USE_STATIC=single E2E_ROUTER_SRC=04-server-error-boundaries expo start", - "export:04-server-error-boundaries": "E2E_SERVER_FUNCTIONS=1 E2E_ROUTER_JS_ENGINE=hermes EXPO_USE_STATIC=single E2E_ROUTER_SRC=04-server-error-boundaries expo export", + "start:01-rsc": "E2E_RSC_ENABLED=1 EXPO_USE_STATIC=server E2E_ROUTER_SRC=01-rsc expo start", + "export:01-rsc": "E2E_RSC_ENABLED=1 EXPO_USE_STATIC=server E2E_ROUTER_SRC=01-rsc expo export", + "start:02-server-actions": "E2E_SERVER_FUNCTIONS=1 E2E_RSC_ENABLED=1 EXPO_USE_STATIC=server E2E_ROUTER_SRC=02-server-actions expo start", + "export:02-server-actions": "E2E_SERVER_FUNCTIONS=1 E2E_RSC_ENABLED=1 EXPO_USE_STATIC=server E2E_ROUTER_SRC=02-server-actions expo export", + "start:03-server-actions-only": "E2E_SERVER_FUNCTIONS=1 EXPO_USE_STATIC=single E2E_ROUTER_SRC=03-server-actions-only expo start", + "export:03-server-actions-only": "E2E_SERVER_FUNCTIONS=1 EXPO_USE_STATIC=single E2E_ROUTER_SRC=03-server-actions-only expo export", + "start:04-server-error-boundaries": "E2E_SERVER_FUNCTIONS=1 EXPO_USE_STATIC=single E2E_ROUTER_SRC=04-server-error-boundaries expo start", + "export:04-server-error-boundaries": "E2E_SERVER_FUNCTIONS=1 EXPO_USE_STATIC=single E2E_ROUTER_SRC=04-server-error-boundaries expo export", "start:05-use-dom": "E2E_ROUTER_SRC=05-use-dom expo start -d", "export:05-use-dom": "E2E_ROUTER_SRC=05-use-dom expo export -p web", "ios:05-use-dom": "E2E_ROUTER_SRC=05-use-dom expo run:ios", @@ -29,12 +29,12 @@ "start:native-tabs": "E2E_ROUTER_SRC=native-tabs expo start", "start:native-navigation": "E2E_ROUTER_SRC=native-navigation expo start", "ios:native-navigation": "E2E_ROUTER_SRC=native-navigation expo run:ios", - "android:native-navigation": "E2E_ROUTER_JS_ENGINE=hermes E2E_ROUTER_SRC=native-navigation expo run:android", - "export:native-navigation": "EXPO_ATLAS=1 E2E_ROUTER_JS_ENGINE=hermes E2E_ROUTER_SRC=native-navigation expo export", + "android:native-navigation": "E2E_ROUTER_SRC=native-navigation expo run:android", + "export:native-navigation": "EXPO_ATLAS=1 E2E_ROUTER_SRC=native-navigation expo export", "start:headless": "E2E_ROUTER_SRC=headless expo", - "start:web-modal": "E2E_ROUTER_SRC=web-modal E2E_ROUTER_JS_ENGINE=hermes EXPO_WEB_DEV_HYDRATE=1 expo start", - "export:web-modal": "E2E_ROUTER_SRC=web-modal E2E_ROUTER_JS_ENGINE=hermes expo export -p web", - "start:fast-refresh": "E2E_ROUTER_SRC=fast-refresh E2E_ROUTER_JS_ENGINE=hermes expo start -w", + "start:web-modal": "E2E_ROUTER_SRC=web-modal EXPO_WEB_DEV_HYDRATE=1 expo start", + "export:web-modal": "E2E_ROUTER_SRC=web-modal expo export -p web", + "start:fast-refresh": "E2E_ROUTER_SRC=fast-refresh expo start -w", "start:middleware-async": "E2E_ROUTER_SRC=server-middleware-async EXPO_USE_STATIC=server E2E_ROUTER_SERVER_MIDDLEWARE=true expo start", "export:middleware-async": "E2E_ROUTER_SRC=server-middleware-async EXPO_USE_STATIC=server E2E_ROUTER_SERVER_MIDDLEWARE=true expo export -p web", "start:static-loader": "E2E_ROUTER_SRC=server-loader E2E_ROUTER_SERVER_LOADERS=true expo start", @@ -56,7 +56,7 @@ "android:stack": "E2E_ROUTER_SRC=stack expo run:android", "start:toolbar-press": "E2E_ROUTER_SRC=toolbar-press expo start", "ios:toolbar-press": "E2E_ROUTER_SRC=toolbar-press expo run:ios", - "android:toolbar-press": "E2E_ROUTER_JS_ENGINE=hermes E2E_ROUTER_SRC=toolbar-press expo run:android", + "android:toolbar-press": "E2E_ROUTER_SRC=toolbar-press expo run:android", "start:helmet": "E2E_ROUTER_SRC=helmet expo start", "export:helmet": "E2E_ROUTER_SRC=helmet expo export -p web", "lint": "oxlint --config oxlint.config.mjs .", diff --git a/apps/test-suite/tests/Location.js b/apps/test-suite/tests/Location.ts similarity index 80% rename from apps/test-suite/tests/Location.js rename to apps/test-suite/tests/Location.ts index 1d139254d85680..6a7425feb5d6f9 100644 --- a/apps/test-suite/tests/Location.js +++ b/apps/test-suite/tests/Location.ts @@ -1,52 +1,54 @@ -'use strict'; - import Constants from 'expo-constants'; import * as Location from 'expo-location'; import * as TaskManager from 'expo-task-manager'; import { Platform } from 'react-native'; import * as TestUtils from '../TestUtils'; +import type { JasmineInterface } from '../types'; +import { requireNotNull } from '../utils/requireNotNull'; const BACKGROUND_LOCATION_TASK = 'background-location-updates'; const GEOFENCING_TASK = 'geofencing-task'; +const GEOCODING_TIMEOUT = 10000; // Allow time for network requests. export const name = 'Location'; -export async function test(t) { +export async function test(t: JasmineInterface) { const shouldSkipTestsRequiringPermissions = await TestUtils.shouldSkipTestsRequiringPermissionsAsync(); const describeWithPermissions = shouldSkipTestsRequiringPermissions ? t.xdescribe : t.describe; - const testShapeOrUnauthorized = (testFunction) => async () => { - const providerStatus = await Location.getProviderStatusAsync(); - if (providerStatus.locationServicesEnabled) { - const { status } = await TestUtils.acceptPermissionsAndRunCommandAsync(() => { - return Location.requestForegroundPermissionsAsync(); - }); - if (status === 'granted') { - const location = await testFunction(); - testLocationShape(location); + const testShapeOrUnauthorized = + (testFunction: () => Promise) => async () => { + const providerStatus = await Location.getProviderStatusAsync(); + if (providerStatus.locationServicesEnabled) { + const { status } = await TestUtils.acceptPermissionsAndRunCommandAsync(() => { + return Location.requestForegroundPermissionsAsync(); + }); + if (status === 'granted') { + const location = await testFunction(); + testLocationShape(location); + } else { + let error: any; + try { + await testFunction(); + } catch (e) { + error = e; + } + t.expect(error.message).toMatch(/Not authorized/); + } } else { - let error; + let error: any; try { await testFunction(); } catch (e) { error = e; } - t.expect(error.message).toMatch(/Not authorized/); + t.expect(error.message).toMatch(/Location services are disabled/); } - } else { - let error; - try { - await testFunction(); - } catch (e) { - error = e; - } - t.expect(error.message).toMatch(/Location services are disabled/); - } - }; + }; - function testLocationShape(location) { + function testLocationShape(location: Location.LocationObject) { t.expect(typeof location === 'object').toBe(true); const { coords, timestamp } = location; @@ -62,6 +64,28 @@ export async function test(t) { t.expect(typeof timestamp === 'number').toBe(true); } + /** + * Awaits a geocoding call, marking the spec pending when the device has no + * Geocoder — Android images without Google APIs ship none and reject with + * `ERR_NO_GEOCODE`. Any other rejection still fails the spec. + * + * This has to be decided per call rather than up front: the Android module checks + * the foreground permission before it checks for a Geocoder, so before the + * permission specs above have run, a missing Geocoder is indistinguishable from a + * missing permission. + */ + async function geocodingOrPending(call: () => Promise): Promise { + try { + return await call(); + } catch (error: any) { + if (error?.code === 'ERR_NO_GEOCODE') { + // `pending` throws to abort the spec, so nothing below it runs. + t.pending('the device has no Geocoder'); + } + throw error; + } + } + t.describe('Location', () => { // On Android, foreground permission needs to be asked before the background permissions describeWithPermissions('Location.requestForegroundPermissionsAsync()', () => { @@ -70,7 +94,9 @@ export async function test(t) { t.expect(permission.granted).toBe(true); t.expect(permission.status).toBe(Location.PermissionStatus.GRANTED); if (Platform.OS === 'ios') { - t.expect(permission.scope).toBe('whenInUse'); + // `always` also grants foreground access, and the two scopes cannot both + // be held: once the background specs below grant `always`, this reports it. + t.expect(['whenInUse', 'always']).toContain(permission.ios?.scope); } }); }); @@ -81,7 +107,9 @@ export async function test(t) { t.expect(permission.granted).toBe(true); t.expect(permission.status).toBe(Location.PermissionStatus.GRANTED); if (Platform.OS === 'ios') { - t.expect(permission.scope).toBe('whenInUse'); + // `always` also grants foreground access, and the two scopes cannot both + // be held: once the background specs below grant `always`, this reports it. + t.expect(['whenInUse', 'always']).toContain(permission.ios?.scope); } }); }); @@ -92,7 +120,7 @@ export async function test(t) { t.expect(permission.granted).toBe(true); t.expect(permission.status).toBe(Location.PermissionStatus.GRANTED); if (Platform.OS === 'ios') { - t.expect(permission.scope).toBe('always'); + t.expect(permission.ios?.scope).toBe('always'); } }); }); @@ -103,7 +131,7 @@ export async function test(t) { t.expect(permission.granted).toBe(true); t.expect(permission.status).toBe(Location.PermissionStatus.GRANTED); if (Platform.OS === 'ios') { - t.expect(permission.scope).toBe('always'); + t.expect(permission.ios?.scope).toBe('always'); } }); }); @@ -121,7 +149,7 @@ export async function test(t) { 'checks if location services are enabled', async () => { const result = await Location.getProviderStatusAsync(); - t.expect(result.locationServicesEnabled).not.toBe(undefined); + t.expect(result.locationServicesEnabled).toBeDefined(); }, timeout ); @@ -167,9 +195,9 @@ export async function test(t) { const result = await Location.getProviderStatusAsync(); t.expect(result.networkAvailable).toBe(true); } - } catch (error) { + } catch (error: any) { // User has denied the dialog. - t.expect(error.code).toBe('E_LOCATION_SETTINGS_UNSATISFIED'); + t.expect(error.code).toBe('ERR_LOCATION_SETTINGS_UNSATISFIED'); } }, 20000 @@ -278,7 +306,9 @@ export async function test(t) { t.it( 'gets a result of the correct shape, or throws error if no permission or disabled', - testShapeOrUnauthorized(() => Location.getLastKnownPositionAsync()), + testShapeOrUnauthorized(async () => + requireNotNull(await Location.getLastKnownPositionAsync()) + ), timeout ); @@ -292,7 +322,7 @@ export async function test(t) { t.expect(current).not.toBeNull(); t.expect(lastKnown).not.toBeNull(); - t.expect(lastKnown.timestamp).toBeGreaterThanOrEqual(current.timestamp); + t.expect(lastKnown?.timestamp).toBeGreaterThanOrEqual(current.timestamp); }, timeout ); @@ -354,14 +384,14 @@ export async function test(t) { describeWithPermissions('Location.watchPositionAsync()', () => { t.it('gets a result of the correct shape', async () => { - let subscriber; - const location = await new Promise(async (resolve) => { + let subscriber: Location.LocationSubscription | undefined; + const location = await new Promise(async (resolve) => { subscriber = await Location.watchPositionAsync({}, (location) => { setTimeout(() => resolve(location)); }); }); - subscriber.remove(); + subscriber?.remove(); testLocationShape(location); }); @@ -381,7 +411,7 @@ export async function test(t) { if (Platform.OS !== 'web') { describeWithPermissions('Location.getHeadingAsync()', () => { - const testCompass = (options) => async () => { + const testCompass = () => async () => { // Disable Compass Test if in simulator if (Constants.isDevice) { const { status } = await TestUtils.acceptPermissionsAndRunCommandAsync(() => { @@ -396,7 +426,7 @@ export async function test(t) { let error; try { await Location.getHeadingAsync(); - } catch (e) { + } catch (e: any) { error = e; } t.expect(error.message).toMatch(/Not authorized/); @@ -414,12 +444,12 @@ export async function test(t) { }); t.describe('Location.geocodeAsync()', () => { - const timeout = 2000; - t.it( 'geocodes a place of the right shape', async () => { - const result = await Location.geocodeAsync('900 State St, Salem, OR'); + const result = await geocodingOrPending(() => + Location.geocodeAsync('900 State St, Salem, OR') + ); t.expect(Array.isArray(result)).toBe(true); t.expect(typeof result[0]).toBe('object'); const { latitude, longitude, accuracy, altitude } = result[0]; @@ -428,46 +458,49 @@ export async function test(t) { t.expect(typeof accuracy).toBe('number'); t.expect(typeof altitude).toBe('number'); }, - timeout + GEOCODING_TIMEOUT ); t.it( 'returns an empty array when the address is not found', async () => { - const result = await Location.geocodeAsync(':('); + const result = await geocodingOrPending(() => Location.geocodeAsync(':(')); t.expect(result).toEqual([]); }, - timeout + GEOCODING_TIMEOUT ); }); t.describe('Location.reverseGeocodeAsync()', () => { - const timeout = 2000; - t.it( 'gives a right shape address of a point location', async () => { - const result = await Location.reverseGeocodeAsync({ - latitude: 60.166595, - longitude: 24.944865, - }); + const result = await geocodingOrPending(() => + Location.reverseGeocodeAsync({ + latitude: 60.166595, + longitude: 24.944865, + }) + ); t.expect(Array.isArray(result)).toBe(true); t.expect(typeof result[0]).toBe('object'); - const fields = ['city', 'street', 'region', 'country', 'postalCode', 'name']; + // Reads the first address; the module returns `null` for unresolved fields. + const fields = ['city', 'street', 'region', 'country', 'postalCode', 'name'] as const; fields.forEach((field) => { - t.expect( - typeof result[field] === 'string' || typeof result[field] === 'undefined' - ).toBe(true); + const value = result[0][field]; + t.expect(typeof value === 'string' || value == null).toBe(true); }); }, - timeout + GEOCODING_TIMEOUT ); t.it("throws for a location where `latitude` and `longitude` aren't numbers", async () => { let error; try { await Location.reverseGeocodeAsync({ + // Deliberately not numbers — the spec asserts the module rejects them. + // @ts-expect-error latitude: '60', + // @ts-expect-error longitude: '24', }); } catch (e) { @@ -478,8 +511,11 @@ export async function test(t) { }); describeWithPermissions('Location - background location updates', () => { - async function expectTaskAccuracyToBe(accuracy) { - const locationTask = await TaskManager.getTaskOptionsAsync(BACKGROUND_LOCATION_TASK); + async function expectTaskAccuracyToBe(accuracy: Location.LocationAccuracy) { + const locationTask = + await TaskManager.getTaskOptionsAsync( + BACKGROUND_LOCATION_TASK + ); t.expect(locationTask).toBeDefined(); t.expect(locationTask.accuracy).toBe(accuracy); @@ -536,8 +572,10 @@ export async function test(t) { }, ]; - async function expectTaskRegionsToBeLike(regions) { - const geofencingTask = await TaskManager.getTaskOptionsAsync(GEOFENCING_TASK); + async function expectTaskRegionsToBeLike(regions: Location.LocationRegion[]) { + const geofencingTask = await TaskManager.getTaskOptionsAsync<{ + regions: Location.LocationRegion[]; + }>(GEOFENCING_TASK); t.expect(geofencingTask).toBeDefined(); t.expect(geofencingTask.regions).toBeDefined(); @@ -593,6 +631,7 @@ export async function test(t) { await (async () => { let error; try { + // @ts-expect-error string is not a number await Location.startGeofencingAsync(GEOFENCING_TASK, [{ longitude: 'not a number' }]); } catch (e) { error = e; @@ -606,5 +645,5 @@ export async function test(t) { } // Define empty tasks, otherwise tasks might automatically unregister themselves if no task is defined. -TaskManager.defineTask(BACKGROUND_LOCATION_TASK, () => {}); -TaskManager.defineTask(GEOFENCING_TASK, () => {}); +TaskManager.defineTask(BACKGROUND_LOCATION_TASK, async () => {}); +TaskManager.defineTask(GEOFENCING_TASK, async () => {}); diff --git a/docs/constants/navigation.js b/docs/constants/navigation.js index da2215a743c0c2..8f771480e5c9ae 100644 --- a/docs/constants/navigation.js +++ b/docs/constants/navigation.js @@ -636,6 +636,7 @@ export const eas = [ makePage('eas/observe/eas-cli.mdx'), makePage('eas/observe/eas-update.mdx'), makePage('eas/observe/events.mdx'), + makePage('eas/observe/errors.mdx'), makePage('eas/observe/configuration.mdx'), makeGroup('Integrations', [ makePage('eas/observe/integrations/expo-router.mdx'), diff --git a/docs/pages/eas/observe/dashboard.mdx b/docs/pages/eas/observe/dashboard.mdx index ab558268dbfdeb..47d0f809f3731c 100644 --- a/docs/pages/eas/observe/dashboard.mdx +++ b/docs/pages/eas/observe/dashboard.mdx @@ -27,14 +27,15 @@ Filters control which events are included in the metrics below. - **Time range**: 1 hour, 12 hours, 1 day, 3 days, 7 days, 14 days, 21 days, 30 days, or 60 days. Defaults to **Last 14 Days**. - **Release**: filter to a specific app version, a specific native build, or a specific OTA update. -## Tabs +## Pages -The dashboard groups data into four tabs: +The dashboard groups data into five pages: - **App startup**: startup performance metrics (cold launch, warm launch, bundle load time, time to first render, time to interactive). See the [Metrics reference](/eas/observe/reference/metrics/) for full descriptions. - **EAS Update**: download time for OTA updates and a per-update table. See [EAS Update download performance](/eas/observe/eas-update/) for details. - **Events** (requires SDK 56 and later): user-defined events logged with [`Observe.logEvent`](/eas/observe/events/), with counts and links to drill into each event. - **Navigation** (requires SDK 56 and later): per-route navigation timings with cold vs warm time to first render and time to interactive. Requires [Expo Router](/eas/observe/integrations/expo-router/) or [React Navigation](/eas/observe/integrations/react-navigation/). +- **Errors** (in preview, requires SDK 57 and later): JavaScript errors recorded by the app, with symbolicated stack traces. See [Error reporting](/eas/observe/errors/) for details. ## Metric cards @@ -45,7 +46,7 @@ Each metric appears as a card with a chart and statistical breakdowns. For every - **Min** and **Max**: the fastest and slowest recorded values. - **P90** and **P99**: values below which 90% or 99% of events fall, useful for identifying tail latency. -On the App startup tab, switch between a list layout (one chart per row) and a grid layout. Use the **Show builds** and **Show updates** toggles to control whether release markers appear on charts. +On the App startup page, switch between a list layout (one chart per row) and a grid layout. Use the **Show builds** and **Show updates** toggles to control whether release markers appear on charts. ## Release markers and comparison @@ -57,7 +58,7 @@ Without a release filter, App startup cards also show the metric for the **lates ## Investigating sessions -When something looks off, drill into individual sessions from the **Events** tab or from a release marker popover. A session timeline shows: +When something looks off, drill into individual sessions from the **Events** page or from a release marker popover. A session timeline shows: - All events recorded during that session (startup, user-defined, and update download events). - Device metadata: platform, app version, build number, OS, and timestamps. diff --git a/docs/pages/eas/observe/eas-update.mdx b/docs/pages/eas/observe/eas-update.mdx index 2d54d9306bccc7..2afbb0c2dc9be7 100644 --- a/docs/pages/eas/observe/eas-update.mdx +++ b/docs/pages/eas/observe/eas-update.mdx @@ -9,21 +9,21 @@ import { Terminal } from '~/ui/components/Snippet'; EAS Observe automatically tracks how long each OTA update takes to download on real user devices. You don't need to add any instrumentation: if your app uses [EAS Update](/eas-update/introduction/) and includes [`expo-observe`](/eas/observe/get-started/), EAS Observe collects download metrics for every update fetch. -Update download times appear in the **EAS Update** tab of the EAS Observe dashboard and are queryable from the EAS CLI. +Update download times appear in the **EAS Update** page of the EAS Observe dashboard and are queryable from the EAS CLI. ## View update downloads -In the dashboard: open your project and navigate to [**Observe > EAS Update**](https://expo.dev/accounts/[account]/projects/[project]/observe?tab=eas-update). The tab has two sections: an aggregate chart and a per-update table. +In the dashboard: open your project and navigate to [**Observe > EAS Update**](https://expo.dev/accounts/[account]/projects/[project]/observe?tab=eas-update). The page has two sections: an aggregate chart and a per-update table. ### Update download time -A single chart showing aggregate download time across all updates fetched in the selected time range. Statistical breakdowns mirror the App startup tab: **Median**, **Avg**, **Min**, **Max**, **P90**, and **P99**. Use these to spot regressions in update size or CDN latency. +A single chart showing aggregate download time across all updates fetched in the selected time range. Statistical breakdowns mirror the App startup page: **Median**, **Avg**, **Min**, **Max**, **P90**, and **P99**. Use these to spot regressions in update size or CDN latency. Update markers on the chart indicate when each update first downloaded. Click a marker to see the update ID, version, and metrics at that point. diff --git a/docs/pages/eas/observe/errors.mdx b/docs/pages/eas/observe/errors.mdx new file mode 100644 index 00000000000000..ec4966bfb02f99 --- /dev/null +++ b/docs/pages/eas/observe/errors.mdx @@ -0,0 +1,121 @@ +--- +title: Error reporting +sidebar_title: Errors +description: Record JavaScript errors from your app and investigate symbolicated stack traces in the EAS Observe dashboard. +--- + +> **important** Error reporting in EAS Observe is in [preview](/more/release-statuses/#preview) and requires SDK 57 or later. Source maps for EAS Update, native crash reporting, and more are still to come. + +The `expo-observe` library records JavaScript errors from your app alongside its performance metrics. Errors are persisted on-device, batched, and dispatched on the next flush. They appear in the **Errors** page of the EAS Observe dashboard. + +Errors are captured through three paths: unhandled errors are recorded automatically, render errors are caught by `ObserveErrorBoundary`, and handled errors can be reported with `Observe.reportError`. + +## Unhandled errors + +Unhandled JavaScript errors are recorded automatically. The library installs a global error handler when it is first imported, so no setup is required. React Native's own behavior is unchanged: the red box still appears in development, and fatal errors still terminate the app in production. + +To turn off automatic recording, set `errorHandlingEnabled` to `false` via [`configure()`](/versions/latest/sdk/observe/#configureconfig): + +```tsx +import { Observe } from 'expo-observe'; + +Observe.configure({ + errorHandlingEnabled: false, +}); +``` + +This only affects unhandled errors. Errors caught by `ObserveErrorBoundary` or reported with `Observe.reportError` are still recorded. + +## Render errors + +Without an error boundary, an error thrown while rendering is recorded by the global error handler as an unhandled error. Wrap a subtree with `ObserveErrorBoundary` to record it together with the React component stack and show a fallback UI in place of the subtree that threw: + +```tsx +import { ObserveErrorBoundary } from 'expo-observe'; + +export default function FeedScreen() { + return ( + }> + + + ); +} +``` + +The `fallback` prop accepts a React element, `null`, or a function that receives the thrown `error` and a `resetError` callback. Calling `resetError()` clears the caught error and re-mounts the children, so they restart from a clean state. + +To place a boundary around your whole app, pass `errorBoundaryFallback` to the `ObserveRoot` component instead of wrapping it manually: + +```tsx src/app/_layout.tsx +import { ObserveRoot } from 'expo-observe'; + +export default function RootLayout() { + return ( + }> + + + ); +} +``` + +Render errors that no boundary catches are still recorded by the global error handler. + +## Handled errors + +Errors your code catches and recovers from reach neither the global handler nor an error boundary. Report them with `Observe.reportError`: + +```tsx +import { Observe } from 'expo-observe'; + +async function handleSync() { + try { + await syncCart(); + } catch (error) { + Observe.reportError(error); + } +} +``` + +`reportError` accepts any thrown value. An `Error` contributes its name, message, and stack trace. Any other value (a string, a plain object, a number) is stringified into the message without a stack trace. + +Avoid Personally Identifiable Information (PII) in error messages. Everything you report is visible in the dashboard and is dispatched off-device. + +## Symbolicated stack traces + +In a production app, your JavaScript is bundled and minified. Stack traces point at line and column positions in the generated bundle, not in your source files. A source map translates those positions back. When a source map is stored for a build, the dashboard shows the original file, line, and column for each frame, and links the build the error came from next to the stack trace. + +If no source map is stored for the build, the dashboard shows the reported stack trace as-is. Frames then reference positions in the minified bundle, such as `index.android.bundle:1:481231`, and are difficult to map back to your code. + +### Upload source maps with EAS Build + +To store a source map for each build, set `uploadSourceMaps` to `true` in the build profile in **eas.json**: + +```json eas.json +{ + "build": { + "production": { + "uploadSourceMaps": true + } + } +} +``` + +With this setting, EAS Build uploads the source map produced when your app's JavaScript is bundled. Symbolication then works for every error reported from that build. No changes to your app code are required. + +> **Note**: Source map upload requires EAS CLI version 22.0.0 or later and only works for builds that run on EAS Build servers. Local builds created with `eas build --local` do not upload source maps. + +The source code embedded in the source map (`sourcesContent`) is removed before upload. Only file names and position mappings are stored. If the upload fails, the build still completes and shows a warning in the build logs. + +## View errors + +Open your project and navigate to [**Observe > Errors**](https://expo.dev/accounts/[account]/projects/[project]/observe/errors). The page lists the errors recorded in the selected time range. Click an error to see its stack trace and details. + +## Still to come + +Error reporting is in preview, and the following are not available yet: + +- **Source maps for EAS Update**: errors from an app running an OTA update show the unsymbolicated stack trace. +- **Native crash reporting**, including iOS symbolication, and more. + +For native crash reporting today, use a service such as [Sentry](/guides/using-sentry/) or [BugSnag](/guides/using-bugsnag/). diff --git a/docs/pages/eas/observe/events.mdx b/docs/pages/eas/observe/events.mdx index dac3938ea00ab6..03aff6fd1c2674 100644 --- a/docs/pages/eas/observe/events.mdx +++ b/docs/pages/eas/observe/events.mdx @@ -9,7 +9,7 @@ import { Terminal } from '~/ui/components/Snippet'; User-defined events let you record arbitrary, named events from your app. Use them to track any signal specific to your app that the built-in performance metrics do not cover. -Events are persisted on-device, batched, and dispatched on the next flush as OpenTelemetry log records. They appear in the **Events** tab of the EAS Observe dashboard and are queryable from the EAS CLI. +Events are persisted on-device, batched, and dispatched on the next flush as OpenTelemetry log records. They appear in the **Events** page of the EAS Observe dashboard and are queryable from the EAS CLI. ## Log an event @@ -96,7 +96,7 @@ Observe.logEvent('onboarding.completed', { In the dashboard: open your project and navigate to [**Observe > Events**](https://expo.dev/accounts/[account]/projects/[project]/observe/events). The default view lists distinct event names with their counts in the selected time range. Click an event name to see individual events with their timestamps, attributes, and the session they belong to. diff --git a/docs/pages/eas/observe/integrations/expo-router.mdx b/docs/pages/eas/observe/integrations/expo-router.mdx index 4d3e33a96c43c5..c8b52fa9ba93cd 100644 --- a/docs/pages/eas/observe/integrations/expo-router.mdx +++ b/docs/pages/eas/observe/integrations/expo-router.mdx @@ -137,7 +137,7 @@ Emitted at most once per screen instance within a session. ## View navigation metrics -In the dashboard, open your project, navigate to [**Observe**](https://expo.dev/accounts/[account]/projects/[project]/observe), and select the **Navigation** tab. It shows per-route navigation timings with cold and warm time to first render and time to interactive. +In the dashboard, open your project, navigate to [**Observe**](https://expo.dev/accounts/[account]/projects/[project]/observe), and select the **Navigation** page. It shows per-route navigation timings with cold and warm time to first render and time to interactive. In the CLI, you can run the following commands: diff --git a/docs/pages/eas/observe/integrations/react-navigation.mdx b/docs/pages/eas/observe/integrations/react-navigation.mdx index cce89df549b582..0cd884cced422d 100644 --- a/docs/pages/eas/observe/integrations/react-navigation.mdx +++ b/docs/pages/eas/observe/integrations/react-navigation.mdx @@ -200,7 +200,7 @@ Emitted at most once per screen instance within a session. ## View navigation metrics -In the dashboard, open your project, navigate to [**Observe**](https://expo.dev/accounts/[account]/projects/[project]/observe), and select the **Navigation** tab. It shows per-screen navigation timings with cold and warm time to first render and time to interactive. +In the dashboard, open your project, navigate to [**Observe**](https://expo.dev/accounts/[account]/projects/[project]/observe), and select the **Navigation** page. It shows per-screen navigation timings with cold and warm time to first render and time to interactive. In the CLI, you can run the following commands: diff --git a/docs/pages/eas/observe/introduction.mdx b/docs/pages/eas/observe/introduction.mdx index 3d6863c1eeb0ce..bdc9e551e5c84a 100644 --- a/docs/pages/eas/observe/introduction.mdx +++ b/docs/pages/eas/observe/introduction.mdx @@ -71,6 +71,7 @@ Traditional development-time profiling tools show how your app performs on your - **Per-route navigation metrics**: Compare render and interactive timings by route with the [Expo Router](/eas/observe/integrations/expo-router/) or [React Navigation](/eas/observe/integrations/react-navigation/) integration - **Session investigation**: Drill into individual user sessions to understand why certain devices or conditions lead to slower performance - **User-defined events**: Log custom signals from your app with `Observe.logEvent` and analyze them alongside performance data +- **Error reporting** (in preview): [Record JavaScript errors](/eas/observe/errors/) and investigate symbolicated stack traces in the dashboard - **CLI and dashboard access**: Query metrics from the terminal with `eas observe:` commands or view them in the EAS dashboard ## When to use EAS Observe @@ -84,12 +85,15 @@ Traditional development-time profiling tools show how your app performs on your | Track EAS Update download times | | | Query performance metrics from the CLI | | | Track user-defined events from your app | | +| Track JavaScript errors in production (in preview) | | | Development-time profiling and debugging | | -| Crash reporting and error tracking | | +| Native crash reporting | | **Development-time profiling and debugging**: Use [React Native DevTools](/debugging/tools/#debugging-with-react-native-devtools) for debugging and [Expo Atlas](/guides/analyzing-bundles/) for bundle inspection. -**Crash reporting and error tracking**: On SDK 57 and later, the [`expo-observe`](/versions/latest/sdk/observe/) library can record JavaScript errors with `ObserveErrorBoundary` and `reportError`. However, the EAS Observe dashboard and the EAS CLI do not display these errors yet. Until they do, we suggest a crash reporting service such as [Sentry](/guides/using-sentry/) or [BugSnag](/guides/using-bugsnag/). +**JavaScript errors**: On SDK 57 and later, EAS Observe records JavaScript errors and shows them in the dashboard with symbolicated stack traces. This feature is in [preview](/more/release-statuses/#preview). See [Error reporting](/eas/observe/errors/) for setup and current limitations. + +**Native crash reporting**: EAS Observe does not capture native crashes yet. Until it does, use a crash reporting service such as [Sentry](/guides/using-sentry/) or [BugSnag](/guides/using-bugsnag/). ## Frequently asked questions (FAQ) @@ -97,7 +101,7 @@ Traditional development-time profiling tools show how your app performs on your -EAS Observe tracks startup metrics (cold launch time, warm launch time, time to first render, time to interactive, and bundle load time) and [EAS Update download time](/eas/observe/eas-update/). On SDK 56 and later, the [navigation integrations](/eas/observe/integrations/expo-router/) add per-route render and interactive timings. See the [Metrics reference](/eas/observe/reference/metrics/) for detailed descriptions of each metric. You can also log your own signals as [user-defined events](/eas/observe/events/). +EAS Observe tracks startup metrics (cold launch time, warm launch time, time to first render, time to interactive, and bundle load time) and [EAS Update download time](/eas/observe/eas-update/). On SDK 56 and later, the [navigation integrations](/eas/observe/integrations/expo-router/) add per-route render and interactive timings. See the [Metrics reference](/eas/observe/reference/metrics/) for detailed descriptions of each metric. You can also log your own signals as [user-defined events](/eas/observe/events/) and record [JavaScript errors](/eas/observe/errors/) (in preview). @@ -162,6 +166,13 @@ Metric data is retained for a minimum of 60 days. Icon={ActivityIcon} /> + + **warning** `expo-observe` is not available in Expo Go. To use it, create a [development build](/develop/development-builds/introduction/). diff --git a/docs/pages/versions/v57.0.0/sdk/observe.mdx b/docs/pages/versions/v57.0.0/sdk/observe.mdx index 614be80902e934..def61758e6afd5 100644 --- a/docs/pages/versions/v57.0.0/sdk/observe.mdx +++ b/docs/pages/versions/v57.0.0/sdk/observe.mdx @@ -19,7 +19,7 @@ Beyond app startup metrics, the library can also: - Collect per-route navigation metrics with the [Expo Router](/eas/observe/integrations/expo-router/) or [React Navigation](/eas/observe/integrations/react-navigation/) integration. - Log [user-defined events](/eas/observe/events/) with `Observe.logEvent`. - Track [EAS Update download times](/eas/observe/eas-update/) automatically. -- Record JavaScript errors with `ObserveErrorBoundary` and `reportError`. The EAS Observe dashboard and the EAS CLI do not display these errors yet. +- Record [JavaScript errors](/eas/observe/errors/) with `ObserveErrorBoundary` and `Observe.reportError`, and view them with symbolicated stack traces in the EAS Observe dashboard (in [preview](/more/release-statuses/#preview)). - Let third-party packages [register their own integrations](/eas/observe/integrations/third-party/) with `Observe.registerIntegration`. > **warning** `expo-observe` is not available in Expo Go. To use it, create a [development build](/develop/development-builds/introduction/). diff --git a/docs/public/static/schemas/unversioned/eas-json-build-common-schema.js b/docs/public/static/schemas/unversioned/eas-json-build-common-schema.js index c7aad5d7df978d..e41d717b86d011 100644 --- a/docs/public/static/schemas/unversioned/eas-json-build-common-schema.js +++ b/docs/public/static/schemas/unversioned/eas-json-build-common-schema.js @@ -89,6 +89,15 @@ export default [ 'List of paths (or patterns) where EAS Build is going to look for the build artifacts. Use `applicationArchivePath` for specifying the path for uploading the application archive. Build artifacts are uploaded even if the build fails. EAS Build uses [glob patterns](https://github.com/isaacs/node-glob#glob-primer) for pattern matching.', ], }, + { + name: 'uploadSourceMaps', + type: 'boolean', + description: [ + 'If set to `true`, the JavaScript source map generated during the build is uploaded to EAS. Stored source maps are used by [EAS Observe](/eas/observe/errors/#symbolicated-stack-traces) to symbolicate reported error stack traces. The source code embedded in the map is removed before upload. Defaults to `false`.', + '', + '**Note**: only available for builds that run on EAS Build servers. Local builds do not upload source maps.', + ], + }, { name: 'node', type: 'string', diff --git a/packages/@expo/cli/docs/testing.md b/packages/@expo/cli/docs/testing.md index eb4698ad38b0fb..7934ece127cba6 100644 --- a/packages/@expo/cli/docs/testing.md +++ b/packages/@expo/cli/docs/testing.md @@ -377,11 +377,10 @@ apps/router-e2e/__e2e__/ Configure which project to run via environment variables: -| Variable | Description | -| ---------------------- | ------------------------------------------------------ | -| `E2E_ROUTER_SRC` | Subdirectory name in `__e2e__/` (e.g., `fast-refresh`) | -| `E2E_ROUTER_JS_ENGINE` | JavaScript engine (`hermes`, `jsc`) | -| `E2E_ROUTER_ASYNC` | Async chunk loading mode | +| Variable | Description | +| ------------------ | ------------------------------------------------------ | +| `E2E_ROUTER_SRC` | Subdirectory name in `__e2e__/` (e.g., `fast-refresh`) | +| `E2E_ROUTER_ASYNC` | Async chunk loading mode | ### Running Router E2E Tests diff --git a/packages/@expo/cli/e2e/__tests__/export-embed-test.ts b/packages/@expo/cli/e2e/__tests__/export-embed-test.ts index 25199ea67e7ff6..d9c41381419639 100644 --- a/packages/@expo/cli/e2e/__tests__/export-embed-test.ts +++ b/packages/@expo/cli/e2e/__tests__/export-embed-test.ts @@ -109,7 +109,6 @@ it('runs `npx expo export:embed`', async () => { env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'static', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: 'static-rendering', E2E_ROUTER_ASYNC: 'development', }, @@ -351,7 +350,6 @@ it('runs `npx expo export:embed --bytecode`', async () => { env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'static', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: 'static-rendering', E2E_ROUTER_ASYNC: 'development', }, diff --git a/packages/@expo/cli/e2e/__tests__/export/export-embed-rsc.test.ts b/packages/@expo/cli/e2e/__tests__/export/export-embed-rsc.test.ts index ee12456730cecf..ee0d67dd562456 100644 --- a/packages/@expo/cli/e2e/__tests__/export/export-embed-rsc.test.ts +++ b/packages/@expo/cli/e2e/__tests__/export/export-embed-rsc.test.ts @@ -54,7 +54,6 @@ jest.unmock('resolve-from'); E2E_ROUTER_ASYNC: 'development', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_RSC_ENABLED: '1', TEST_SECRET_VALUE: 'test-secret', diff --git a/packages/@expo/cli/e2e/__tests__/export/export-server-magic-import.test.ts b/packages/@expo/cli/e2e/__tests__/export/export-server-magic-import.test.ts index cb888e4e2a4e04..9ba67d49e888d8 100644 --- a/packages/@expo/cli/e2e/__tests__/export/export-server-magic-import.test.ts +++ b/packages/@expo/cli/e2e/__tests__/export/export-server-magic-import.test.ts @@ -19,7 +19,6 @@ describe('export server with magic import comments', () => { NODE_ENV: 'production', EXPO_USE_STATIC: 'server', E2E_ROUTER_SRC: inputDir, - E2E_ROUTER_JS_ENGINE: 'hermes', }, }); }); diff --git a/packages/@expo/cli/e2e/__tests__/export/no-bytecode.test.ts b/packages/@expo/cli/e2e/__tests__/export/no-bytecode.test.ts index 76045c6b63a147..64dd33d8d571db 100644 --- a/packages/@expo/cli/e2e/__tests__/export/no-bytecode.test.ts +++ b/packages/@expo/cli/e2e/__tests__/export/no-bytecode.test.ts @@ -22,7 +22,6 @@ describe('exports for hermes with no bytecode', () => { env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'static', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: 'url-polyfill', E2E_ROUTER_ASYNC: 'development', }, @@ -79,7 +78,6 @@ describe('exports for hermes with no bytecode and no minification', () => { env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'static', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: 'url-polyfill', E2E_ROUTER_ASYNC: 'development', }, diff --git a/packages/@expo/cli/e2e/__tests__/export/url-polyfill.test.ts b/packages/@expo/cli/e2e/__tests__/export/url-polyfill.test.ts index 977c2316fbfc36..1eaba44ae9f245 100644 --- a/packages/@expo/cli/e2e/__tests__/export/url-polyfill.test.ts +++ b/packages/@expo/cli/e2e/__tests__/export/url-polyfill.test.ts @@ -33,7 +33,7 @@ describe('exports with url-polyfill', () => { fileMetadata: { ios: { assets: expect.anything(), - bundle: expect.stringMatching(/_expo\/static\/js\/ios\/entry-.*\.js/), + bundle: expect.stringMatching(/_expo\/static\/js\/ios\/entry-.*\.hbc$/), }, }, version: 0, diff --git a/packages/@expo/cli/e2e/playwright/dev/01-rsc.test.ts b/packages/@expo/cli/e2e/playwright/dev/01-rsc.test.ts index 57eeef8ff14b7a..bd9bbea4db0b05 100644 --- a/packages/@expo/cli/e2e/playwright/dev/01-rsc.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/01-rsc.test.ts @@ -23,7 +23,6 @@ for (const outputMode of outputModes) { env: { NODE_ENV: 'development', EXPO_USE_STATIC: outputMode, - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: inputDir, E2E_ROUTER_ASYNC: 'development', E2E_RSC_ENABLED: '1', diff --git a/packages/@expo/cli/e2e/playwright/dev/02-server-actions.test.ts b/packages/@expo/cli/e2e/playwright/dev/02-server-actions.test.ts index 0d9d638ff73d7a..9dfa05346891b6 100644 --- a/packages/@expo/cli/e2e/playwright/dev/02-server-actions.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/02-server-actions.test.ts @@ -21,7 +21,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'development', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: testName, E2E_SERVER_FUNCTIONS: '1', E2E_ROUTER_ASYNC: 'development', diff --git a/packages/@expo/cli/e2e/playwright/dev/03-server-actions-only.test.ts b/packages/@expo/cli/e2e/playwright/dev/03-server-actions-only.test.ts index cbf99cef42d215..2ba63967855224 100644 --- a/packages/@expo/cli/e2e/playwright/dev/03-server-actions-only.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/03-server-actions-only.test.ts @@ -29,7 +29,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'development', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: testName, E2E_ROUTER_ASYNC: 'development', E2E_SERVER_FUNCTIONS: '1', diff --git a/packages/@expo/cli/e2e/playwright/dev/04-server-error-boundaries.test.ts b/packages/@expo/cli/e2e/playwright/dev/04-server-error-boundaries.test.ts index e0dcc39047f04a..7bc316434cddf9 100644 --- a/packages/@expo/cli/e2e/playwright/dev/04-server-error-boundaries.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/04-server-error-boundaries.test.ts @@ -20,7 +20,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'development', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: testName, E2E_SERVER_FUNCTIONS: '1', diff --git a/packages/@expo/cli/e2e/playwright/dev/dev-console-errors.test.ts b/packages/@expo/cli/e2e/playwright/dev/dev-console-errors.test.ts index 20505f58e093bc..a92e9bc96724d2 100644 --- a/packages/@expo/cli/e2e/playwright/dev/dev-console-errors.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/dev-console-errors.test.ts @@ -22,7 +22,6 @@ test.describe('dev console errors', () => { env: { NODE_ENV: 'development', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: '06-errors', E2E_ROUTER_ASYNC: 'development', diff --git a/packages/@expo/cli/e2e/playwright/dev/fast-refresh.test.ts b/packages/@expo/cli/e2e/playwright/dev/fast-refresh.test.ts index e95fbe610eae53..a1df0479582e0c 100644 --- a/packages/@expo/cli/e2e/playwright/dev/fast-refresh.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/fast-refresh.test.ts @@ -27,7 +27,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'development', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: inputDir, E2E_ROUTER_ASYNC: 'development', diff --git a/packages/@expo/cli/e2e/playwright/dev/headless.test.ts b/packages/@expo/cli/e2e/playwright/dev/headless.test.ts index 6d1eb97e0b5e5f..fdd23584c8a8d3 100644 --- a/packages/@expo/cli/e2e/playwright/dev/headless.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/headless.test.ts @@ -19,7 +19,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: inputDir, E2E_ROUTER_ASYNC: 'development', diff --git a/packages/@expo/cli/e2e/playwright/dev/metro-resolver.test.ts b/packages/@expo/cli/e2e/playwright/dev/metro-resolver.test.ts index ef2435325abc55..d531d4fec23b41 100644 --- a/packages/@expo/cli/e2e/playwright/dev/metro-resolver.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/metro-resolver.test.ts @@ -13,7 +13,6 @@ test.describe(inputDir, () => { cwd: projectRoot, env: { EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: inputDir, E2E_ROUTER_ASYNC: 'development', // Ensure CI is disabled otherwise the file watcher won't run. diff --git a/packages/@expo/cli/e2e/playwright/dev/native-tabs.test.ts b/packages/@expo/cli/e2e/playwright/dev/native-tabs.test.ts index 456c6b65b6204e..516b597ee1a25c 100644 --- a/packages/@expo/cli/e2e/playwright/dev/native-tabs.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/native-tabs.test.ts @@ -19,7 +19,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: inputDir, E2E_ROUTER_ASYNC: 'development', diff --git a/packages/@expo/cli/e2e/playwright/dev/navigator-browser-history.test.ts b/packages/@expo/cli/e2e/playwright/dev/navigator-browser-history.test.ts index 692445d6e3d9a2..4e870b68a2518f 100644 --- a/packages/@expo/cli/e2e/playwright/dev/navigator-browser-history.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/navigator-browser-history.test.ts @@ -18,7 +18,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: inputDir, E2E_ROUTER_ASYNC: 'development', diff --git a/packages/@expo/cli/e2e/playwright/dev/router-misc.test.ts b/packages/@expo/cli/e2e/playwright/dev/router-misc.test.ts index f0e32d91bec1bf..e1963f7a35c054 100644 --- a/packages/@expo/cli/e2e/playwright/dev/router-misc.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/router-misc.test.ts @@ -19,7 +19,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: inputDir, E2E_ROUTER_ASYNC: 'development', diff --git a/packages/@expo/cli/e2e/playwright/dev/router-prevent-remove.test.ts b/packages/@expo/cli/e2e/playwright/dev/router-prevent-remove.test.ts index 24d3c432aea4cc..9c1312ac854ad7 100644 --- a/packages/@expo/cli/e2e/playwright/dev/router-prevent-remove.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/router-prevent-remove.test.ts @@ -14,7 +14,6 @@ const expoStart = createExpoStart({ env: { NODE_ENV: 'production', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: inputDir, E2E_ROUTER_ASYNC: 'development', CI: '0', diff --git a/packages/@expo/cli/e2e/playwright/dev/web-workers.test.ts b/packages/@expo/cli/e2e/playwright/dev/web-workers.test.ts index 524a55de45f124..4710307b56fbf2 100644 --- a/packages/@expo/cli/e2e/playwright/dev/web-workers.test.ts +++ b/packages/@expo/cli/e2e/playwright/dev/web-workers.test.ts @@ -24,7 +24,6 @@ test.describe(inputDir, () => { env: { NODE_ENV: 'development', EXPO_USE_STATIC: 'single', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_SRC: testName, E2E_ROUTER_ASYNC: 'development', // Ensure CI is disabled otherwise the file watcher won't run. diff --git a/packages/@expo/cli/e2e/playwright/prod/01-rsc.test.ts b/packages/@expo/cli/e2e/playwright/prod/01-rsc.test.ts index 7549ebb964a540..e1124b8ff1c85c 100644 --- a/packages/@expo/cli/e2e/playwright/prod/01-rsc.test.ts +++ b/packages/@expo/cli/e2e/playwright/prod/01-rsc.test.ts @@ -35,7 +35,6 @@ for (const outputMode of outputModes) { await executeExpoAsync(projectRoot, ['export', '-p', 'web', '--output-dir', inputDir], { env: { NODE_ENV: 'production', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_RSC_ENABLED: '1', E2E_ROUTER_SRC: '01-rsc', E2E_BUILD_MARKER: 'static', diff --git a/packages/@expo/cli/e2e/playwright/prod/02-server-actions.test.ts b/packages/@expo/cli/e2e/playwright/prod/02-server-actions.test.ts index aea5d8d3db5c6f..b41321aad8744f 100644 --- a/packages/@expo/cli/e2e/playwright/prod/02-server-actions.test.ts +++ b/packages/@expo/cli/e2e/playwright/prod/02-server-actions.test.ts @@ -32,7 +32,6 @@ test.describe(inputDir, () => { EXPO_USE_STATIC: 'single', E2E_ROUTER_SRC: testName, E2E_SERVER_FUNCTIONS: '1', - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_RSC_ENABLED: '1', TEST_SECRET_VALUE: 'test-secret', CI: '1', diff --git a/packages/@expo/cli/e2e/playwright/prod/03-server-actions-only.test.ts b/packages/@expo/cli/e2e/playwright/prod/03-server-actions-only.test.ts index 619624536ad0db..70c8ccd33ad596 100644 --- a/packages/@expo/cli/e2e/playwright/prod/03-server-actions-only.test.ts +++ b/packages/@expo/cli/e2e/playwright/prod/03-server-actions-only.test.ts @@ -36,7 +36,6 @@ for (const outputMode of staticModes) { EXPO_USE_STATIC: outputMode, E2E_ROUTER_SRC: testName, E2E_SERVER_FUNCTIONS: '1', - E2E_ROUTER_JS_ENGINE: 'hermes', // E2E_RSC_ENABLED: '1', TEST_SECRET_VALUE: 'test-secret', CI: '1', diff --git a/packages/@expo/cli/e2e/playwright/prod/web-workers.test.ts b/packages/@expo/cli/e2e/playwright/prod/web-workers.test.ts index 4e544d78e7c0a4..cb358700fef71e 100644 --- a/packages/@expo/cli/e2e/playwright/prod/web-workers.test.ts +++ b/packages/@expo/cli/e2e/playwright/prod/web-workers.test.ts @@ -31,7 +31,6 @@ test.describe(inputDir, () => { env: { EXPO_USE_STATIC: 'single', E2E_ROUTER_SRC: testName, - E2E_ROUTER_JS_ENGINE: 'hermes', E2E_ROUTER_ASYNC: 'true', CI: '1', }, diff --git a/packages/expo-app-metrics/CHANGELOG.md b/packages/expo-app-metrics/CHANGELOG.md index 0417c259c5664b..76b4fba65a2148 100644 --- a/packages/expo-app-metrics/CHANGELOG.md +++ b/packages/expo-app-metrics/CHANGELOG.md @@ -12,12 +12,14 @@ ### 🐛 Bug fixes +- [iOS] Preserve millisecond precision in log event timestamps. ([#49141](https://github.com/expo/expo/pull/49141) by [@Ubax](https://github.com/Ubax)) - [android] Fix `UnsupportedOperationException` and `NoSuchMethodError` on Android 7.x ([#48577](https://github.com/expo/expo/pull/48577) by [@Ubax](https://github.com/Ubax)) - [iOS] Retry the OTA `AppInfo` patch on updates state changes, so a launch where the module registry is created before `expo-updates` has assigned its startup procedure no longer keeps the embedded build's update attribution for the whole session. ([#48899](https://github.com/expo/expo/pull/48899) by [@spsaucier](https://github.com/spsaucier)) - [iOS] Fix a crash on FirebaseAuth's first token refresh. GTMSessionFetcher branches on the class of `session.delegate`, so our network-observing delegate proxy now answers class and protocol checks for the delegate it wraps. ([#48360](https://github.com/expo/expo/pull/48360) by [@tsapeta](https://github.com/tsapeta)) ### 💡 Others +- [iOS] Add an optional limit when reading pending metric and log rows. ([#49121](https://github.com/expo/expo/pull/49121) by [@Ubax](https://github.com/Ubax)) - [Android] Load only requested metric and log rows when preparing observability payloads. ([#49011](https://github.com/expo/expo/pull/49011) by [@Ubax](https://github.com/Ubax)) - Rename the no-update `downloadComplete` state event to `downloadCompleteUnavailable`. ([#47902](https://github.com/expo/expo/pull/47902) by [@kudo](https://github.com/kudo)) - [iOS] Measure the JS bundle load time against the app startup end marker to stay compatible with upcoming React Native versions. ([#47782](https://github.com/expo/expo/pull/47782) by [@tsapeta](https://github.com/tsapeta)) diff --git a/packages/expo-app-metrics/ios/AppMetrics.swift b/packages/expo-app-metrics/ios/AppMetrics.swift index 4f1e17b658ccea..c26ab462f3dc92 100644 --- a/packages/expo-app-metrics/ios/AppMetrics.swift +++ b/packages/expo-app-metrics/ios/AppMetrics.swift @@ -68,17 +68,17 @@ public struct AppMetrics { /// Returns metric rows whose `id` is greater than `cursor`, in ascending id order. Consumers persist /// the largest seen id and pass it back on subsequent calls to fetch only newer rows. Empty when the - /// database failed to open. + /// database failed to open. Pass `limit` to return at most that many of the oldest rows. @AppMetricsActor - public static func getMetrics(afterId cursor: Int64) throws -> [MetricRow] { - return try database?.getMetrics(afterId: cursor) ?? [] + public static func getMetrics(afterId cursor: Int64, limit: Int? = nil) throws -> [MetricRow] { + return try database?.getMetrics(afterId: cursor, limit: limit) ?? [] } /// Returns log rows whose `id` is greater than `cursor`, in ascending id order. Empty when the - /// database failed to open. + /// database failed to open. Pass `limit` to return at most that many of the oldest rows. @AppMetricsActor - public static func getLogs(afterId cursor: Int64) throws -> [LogRow] { - return try database?.getLogs(afterId: cursor) ?? [] + public static func getLogs(afterId cursor: Int64, limit: Int? = nil) throws -> [LogRow] { + return try database?.getLogs(afterId: cursor, limit: limit) ?? [] } /// Hydrates session rows for the given ids. Used to attach session metadata to a batch of metrics diff --git a/packages/expo-app-metrics/ios/Database/MetricsDatabase.swift b/packages/expo-app-metrics/ios/Database/MetricsDatabase.swift index c8073833c6f65f..642eab9cf2faf3 100644 --- a/packages/expo-app-metrics/ios/Database/MetricsDatabase.swift +++ b/packages/expo-app-metrics/ios/Database/MetricsDatabase.swift @@ -332,14 +332,14 @@ final class MetricsDatabase: Sendable { /// Returns metric rows whose `id` is greater than `cursor`, in ascending id order. Dispatch uses /// this with the persisted "last dispatched metric id" cursor to fetch only new rows. @AppMetricsActor - func getMetrics(afterId cursor: Int64) throws -> [MetricRow] { + func getMetrics(afterId cursor: Int64, limit: Int? = nil) throws -> [MetricRow] { let statement = try database.prepare( """ SELECT id, sessionId, timestamp, category, name, value, routeName, updateId, params - FROM metrics WHERE id > ?1 ORDER BY id ASC + FROM metrics WHERE id > ?1 ORDER BY id ASC LIMIT ?2 """ ) - try statement.bindAll([cursor]) + try statement.bindAll([cursor, limit ?? -1]) var rows: [MetricRow] = [] try statement.forEachRow { row in rows.append(MetricRow(row: row)) @@ -349,14 +349,14 @@ final class MetricsDatabase: Sendable { /// Returns log rows whose `id` is greater than `cursor`, in ascending id order. @AppMetricsActor - func getLogs(afterId cursor: Int64) throws -> [LogRow] { + func getLogs(afterId cursor: Int64, limit: Int? = nil) throws -> [LogRow] { let statement = try database.prepare( """ SELECT id, sessionId, timestamp, severity, name, body, attributes, droppedAttributesCount - FROM logs WHERE id > ?1 ORDER BY id ASC + FROM logs WHERE id > ?1 ORDER BY id ASC LIMIT ?2 """ ) - try statement.bindAll([cursor]) + try statement.bindAll([cursor, limit ?? -1]) var rows: [LogRow] = [] try statement.forEachRow { row in rows.append(LogRow(row: row)) diff --git a/packages/expo-app-metrics/ios/LogEvents/LogRecord.swift b/packages/expo-app-metrics/ios/LogEvents/LogRecord.swift index 67ceab921da72d..46849fdf5a915d 100644 --- a/packages/expo-app-metrics/ios/LogEvents/LogRecord.swift +++ b/packages/expo-app-metrics/ios/LogEvents/LogRecord.swift @@ -13,7 +13,7 @@ public struct LogRecord: Codable, Sendable { public let droppedAttributesCount: Int /// Severity of the event. public let severity: Severity - public var timestamp: String = Date.now.ISO8601Format() + public var timestamp: String = Date.now.ISO8601Format(.init(includingFractionalSeconds: true)) init( name: String, @@ -21,7 +21,7 @@ public struct LogRecord: Codable, Sendable { attributes: [String: Any]? = nil, droppedAttributesCount: Int = 0, severity: Severity = .info, - timestamp: String = Date.now.ISO8601Format() + timestamp: String = Date.now.ISO8601Format(.init(includingFractionalSeconds: true)) ) { self.name = name self.body = body diff --git a/packages/expo-app-metrics/ios/Tests/LogRecordTests.swift b/packages/expo-app-metrics/ios/Tests/LogRecordTests.swift new file mode 100644 index 00000000000000..3cdc1ddeff81b2 --- /dev/null +++ b/packages/expo-app-metrics/ios/Tests/LogRecordTests.swift @@ -0,0 +1,15 @@ +import Foundation +import Testing + +@testable import ExpoAppMetrics + +@Suite("LogRecord") +struct LogRecordTests { + @Test + func `default timestamp includes milliseconds`() throws { + let timestamp = LogRecord(name: "event").timestamp + + #expect(timestamp.range(of: #"\.\d{3}Z$"#, options: .regularExpression) != nil) + _ = try Date.ISO8601FormatStyle().parse(timestamp) + } +} diff --git a/packages/expo-app-metrics/ios/Tests/MetricsDatabaseTests.swift b/packages/expo-app-metrics/ios/Tests/MetricsDatabaseTests.swift index f8fa5ae4b3e613..c4e4272d1dd395 100644 --- a/packages/expo-app-metrics/ios/Tests/MetricsDatabaseTests.swift +++ b/packages/expo-app-metrics/ios/Tests/MetricsDatabaseTests.swift @@ -394,6 +394,21 @@ struct MetricsDatabaseTests { } } + @Test + func `getMetrics after id limits the oldest remaining rows`() throws { + try withTemporaryDatabase { database in + try database.insert(session: makeSessionRow(id: "s")) + let ids = try ["a", "b", "c", "d"].map { + try database.insert(metric: makeMetricRow(sessionId: "s", name: $0)) + } + + #expect(try database.getMetrics(afterId: ids[0], limit: 2).map(\.name) == ["b", "c"]) + #expect(try database.getMetrics(afterId: ids[1], limit: 10).map(\.name) == ["c", "d"]) + #expect(try database.getMetrics(afterId: ids[0], limit: nil).map(\.name) == ["b", "c", "d"]) + #expect(try database.getMetrics(afterId: ids[0]).map(\.name) == ["b", "c", "d"]) + } + } + // MARK: - Logs @Test @@ -479,6 +494,21 @@ struct MetricsDatabaseTests { } } + @Test + func `getLogs after id limits the oldest remaining rows`() throws { + try withTemporaryDatabase { database in + try database.insert(session: makeSessionRow(id: "s")) + let ids = try ["a", "b", "c", "d"].map { + try database.insert(log: makeLogRow(sessionId: "s", name: $0)) + } + + #expect(try database.getLogs(afterId: ids[0], limit: 2).map(\.name) == ["b", "c"]) + #expect(try database.getLogs(afterId: ids[1], limit: 10).map(\.name) == ["c", "d"]) + #expect(try database.getLogs(afterId: ids[0], limit: nil).map(\.name) == ["b", "c", "d"]) + #expect(try database.getLogs(afterId: ids[0]).map(\.name) == ["b", "c", "d"]) + } + } + // MARK: - Crash reports @Test diff --git a/packages/expo-audio/CHANGELOG.md b/packages/expo-audio/CHANGELOG.md index 0082b01abd86d2..ad20d6cce301fe 100644 --- a/packages/expo-audio/CHANGELOG.md +++ b/packages/expo-audio/CHANGELOG.md @@ -4,8 +4,11 @@ ### 🛠 Breaking changes +- [Android] Aligned the default audio focus request on Android 7.0–7.1 with newer versions by using transient exclusive focus when no interruption mode has been configured. ([#49101](https://github.com/expo/expo/pull/49101) by [@behenate](https://github.com/behenate)) + ### 🎉 New features +- Added the `doNotMixPersistent` interruption mode. ([#49101](https://github.com/expo/expo/pull/49101) by [@behenate](https://github.com/behenate)) - Added `fileName` option to `RecordingOptions` to allow specifying the recording file basename on Android and iOS. ([#47265](https://github.com/expo/expo/pull/47265) by [@silwalprabin](https://github.com/silwalprabin)) - Support lockscreen controls with playlists. ([#46020](https://github.com/expo/expo/pull/46020) by [@alanjhughes](https://github.com/alanjhughes)) - Added a `fileSize` field to `RecorderState` reporting the current size of the recording file in bytes. ([#46808](https://github.com/expo/expo/pull/46808) by [@behenate](https://github.com/behenate)) diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt index a43b1a96d665ae..19281370d39f64 100644 --- a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt +++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioModule.kt @@ -84,12 +84,21 @@ class AudioModule : Module() { } private var audioFocusRequest: AudioFocusRequest? = null + private var focusRequestRegistered = false + private var registeredAudioFocusGain: Int? = null + private var shouldRefreshFocusOnGain = false private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange -> appContext.mainQueue.launch { + if (!focusRequestRegistered) { + return@launch + } when (focusChange) { AudioManager.AUDIOFOCUS_LOSS -> { - focusAcquired = false - allPlayables.forEach { it.pause() } + releaseAudioFocus() + allPlayables.forEach { playable -> + playable.isPaused = false + playable.pause() + } } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { @@ -105,12 +114,10 @@ class AudioModule : Module() { AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { if (interruptionMode == InterruptionMode.DUCK_OTHERS) { allPlayables.forEach { playable -> - if (playable.previousVolume != playable.volume) { - playable.previousVolume = playable.volume - } - playable.setVolume(playable.previousVolume * 0.5f) + playable.setVolume(playable.previousVolume * 0.5f, rememberVolume = false) } } else { + focusAcquired = false allPlayables.forEach { playable -> if (playable.isPlaying) { playable.isPaused = true @@ -123,7 +130,42 @@ class AudioModule : Module() { AudioManager.AUDIOFOCUS_GAIN -> { focusAcquired = true - if (!shouldPlayInSilentMode()) { + if (shouldRefreshFocusOnGain) { + val playablesToResume = allPlayables.filter { it.shouldResumeAfterFocus() }.toList() + shouldRefreshFocusOnGain = false + releaseAudioFocus() + allPlayables.forEach { playable -> + playable.setVolume(playable.previousVolume) + } + + if (playablesToResume.isEmpty()) { + return@launch + } + + if (!audioEnabled || !shouldPlayInSilentMode()) { + playablesToResume.forEach { playable -> + playable.isPaused = false + playable.pause() + } + return@launch + } + + when (requestAudioFocus()) { + AudioFocusResult.GRANTED, + AudioFocusResult.NOT_REQUESTED -> resumeInterruptedPlayables(playablesToResume) + AudioFocusResult.DELAYED -> playablesToResume.forEach { playable -> + playable.isPaused = true + playable.pause() + } + AudioFocusResult.FAILED -> playablesToResume.forEach { playable -> + playable.isPaused = false + playable.pause() + } + } + return@launch + } + + if (!audioEnabled || !shouldPlayInSilentMode()) { return@launch } @@ -140,7 +182,17 @@ class AudioModule : Module() { } private fun shouldReleaseFocus(): Boolean { - return allPlayables.none { it.isPlaying } + return focusAcquired && allPlayables.none { it.isPlaying } + } + + private fun Playable.hasActivePlaybackIntent(): Boolean { + return player.playWhenReady && + player.playerError == null && + (player.playbackState == Player.STATE_BUFFERING || player.playbackState == Player.STATE_READY) + } + + private fun Playable.shouldResumeAfterFocus(): Boolean { + return isPaused || hasActivePlaybackIntent() } private fun shouldPlayInSilentMode(): Boolean { @@ -149,22 +201,22 @@ class AudioModule : Module() { private enum class AudioFocusResult { GRANTED, DELAYED, FAILED, NOT_REQUESTED } + private fun audioFocusGainForMode(mode: InterruptionMode?): Int? = when (mode) { + null -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + else -> mode.toAudioFocusGain() + } + private fun requestAudioFocus(): AudioFocusResult { if (focusAcquired) { return AudioFocusResult.GRANTED } - if (!audioEnabled || interruptionMode == InterruptionMode.MIX_WITH_OTHERS) { + if (!audioEnabled) { return AudioFocusResult.NOT_REQUESTED } + val requestType = audioFocusGainForMode(interruptionMode) ?: return AudioFocusResult.NOT_REQUESTED + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val requestType = interruptionMode?.let { - if (it == InterruptionMode.DO_NOT_MIX) { - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT - } else { - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK - } - } ?: AudioManager.AUDIOFOCUS_GAIN_TRANSIENT audioFocusRequest = AudioFocusRequest.Builder(requestType).run { setAudioAttributes( AudioAttributes.Builder() @@ -180,21 +232,25 @@ class AudioModule : Module() { } ?: AudioManager.AUDIOFOCUS_REQUEST_FAILED } else { @Suppress("DEPRECATION") - val requestType = if (interruptionMode == InterruptionMode.DO_NOT_MIX) { - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT - } else { - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK - } audioManager.requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, requestType) } return when (result) { AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> { + shouldRefreshFocusOnGain = false + focusRequestRegistered = true + registeredAudioFocusGain = requestType focusAcquired = true AudioFocusResult.GRANTED } // The system can grant focus later through the listener, so this is not a failure. - AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> AudioFocusResult.DELAYED + AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> { + shouldRefreshFocusOnGain = false + focusRequestRegistered = true + registeredAudioFocusGain = requestType + focusAcquired = false + AudioFocusResult.DELAYED + } else -> { appContext.jsLogger?.warn( "expo-audio couldn't acquire audio focus, so playback won't start. On Android an app can't " + @@ -207,21 +263,87 @@ class AudioModule : Module() { } private fun releaseAudioFocus() { - if (!focusAcquired) { - return - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + audioFocusRequest = null } else { - @Suppress("DEPRECATION") - audioManager.abandonAudioFocus(audioFocusChangeListener) + if (focusRequestRegistered) { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(audioFocusChangeListener) + } } + focusRequestRegistered = false + registeredAudioFocusGain = null + shouldRefreshFocusOnGain = false focusAcquired = false } + private fun releasePendingAudioFocusIfUnused() { + if (focusRequestRegistered && !focusAcquired && allPlayables.none { it.shouldResumeAfterFocus() }) { + releaseAudioFocus() + } + } + + private fun updateAudioFocusForModeChange(previousMode: InterruptionMode?) { + if (audioFocusGainForMode(previousMode) == audioFocusGainForMode(interruptionMode)) { + return + } + + runOnMain { + if (focusRequestRegistered && !focusAcquired) { + val hasPlaybackIntent = allPlayables.any { it.shouldResumeAfterFocus() } + if (!hasPlaybackIntent) { + releaseAudioFocus() + return@runOnMain + } + shouldRefreshFocusOnGain = registeredAudioFocusGain != audioFocusGainForMode(interruptionMode) + return@runOnMain + } + + val playablesWithPlaybackIntent = allPlayables.filter { it.hasActivePlaybackIntent() }.toList() + if (playablesWithPlaybackIntent.isEmpty() && !focusAcquired) { + return@runOnMain + } + releaseAudioFocus() + + if (playablesWithPlaybackIntent.isNotEmpty()) { + val focusResult = requestAudioFocus() + allPlayables.forEach { playable -> + playable.setVolume(playable.previousVolume) + } + when (focusResult) { + AudioFocusResult.GRANTED, + AudioFocusResult.NOT_REQUESTED -> Unit + AudioFocusResult.DELAYED -> { + playablesWithPlaybackIntent.forEach { playable -> + playable.isPaused = true + playable.pause() + } + } + AudioFocusResult.FAILED -> { + playablesWithPlaybackIntent.forEach { playable -> + playable.isPaused = false + playable.pause() + } + } + } + } + } + } + + private fun resumeInterruptedPlayables(playables: List) { + val canResume = audioEnabled && shouldPlayInSilentMode() + playables.forEach { playable -> + playable.setVolume(playable.previousVolume) + playable.isPaused = false + if (canResume) { + playable.play() + } + } + } + @OptIn(DelicateCoroutinesApi::class) override fun definition() = ModuleDefinition { Name("ExpoAudio") @@ -232,8 +354,9 @@ class AudioModule : Module() { } AsyncFunction("setAudioModeAsync") { mode: AudioMode -> + val previousInterruptionMode = interruptionMode shouldPlayInBackground = mode.shouldPlayInBackground - interruptionMode = mode.interruptionMode + interruptionMode = mode.interruptionMode ?: previousInterruptionMode playsInSilentMode = mode.playsInSilentMode updatePlaySoundThroughEarpiece(mode.shouldRouteThroughEarpiece ?: false) allowsBackgroundRecording = mode.allowsBackgroundRecording @@ -256,14 +379,17 @@ class AudioModule : Module() { } } } + + updateAudioFocusForModeChange(previousInterruptionMode) } AsyncFunction("setIsAudioActiveAsync") { enabled: Boolean -> audioEnabled = enabled if (!enabled) { - releaseAudioFocus() runOnMain { + releaseAudioFocus() allPlayables.forEach { + it.isPaused = false if (it.isPlaying) { it.pause() } @@ -331,14 +457,13 @@ class AudioModule : Module() { return@OnActivityEntersForeground } - if (allPlayables.any { it.isPaused }) { - requestAudioFocus() - } - - allPlayables.forEach { playable -> - if (playable.isPaused) { - playable.isPaused = false - playable.play() + val interruptedPlayables = allPlayables.filter { it.isPaused }.toList() + if (interruptedPlayables.isNotEmpty()) { + when (requestAudioFocus()) { + AudioFocusResult.GRANTED, + AudioFocusResult.NOT_REQUESTED -> resumeInterruptedPlayables(interruptedPlayables) + AudioFocusResult.DELAYED -> Unit + AudioFocusResult.FAILED -> interruptedPlayables.forEach { it.isPaused = false } } } } @@ -386,8 +511,12 @@ class AudioModule : Module() { bufferDurationMs ) player.onPlaybackStateChange = { isPlaying -> - if (!isPlaying && shouldReleaseFocus()) { - releaseAudioFocus() + if (!isPlaying) { + if (shouldReleaseFocus()) { + releaseAudioFocus() + } else { + releasePendingAudioFocusIfUnused() + } } } players[player.id] = player @@ -505,7 +634,9 @@ class AudioModule : Module() { Function("pause") { player: AudioPlayer -> runOnMain { + player.isPaused = false player.ref.pause() + releasePendingAudioFocusIfUnused() } } @@ -514,6 +645,8 @@ class AudioModule : Module() { if (player.ref.availableCommands.contains(Player.COMMAND_CHANGE_MEDIA_ITEMS)) { if (source == null) { player.clearMediaSource() + player.isPaused = false + releasePendingAudioFocusIfUnused() return@runOnMain } val mediaSource = createMediaItem(source) @@ -569,7 +702,10 @@ class AudioModule : Module() { } Function("remove") { player: AudioPlayer -> - players.remove(player.id) + runOnMain { + players.remove(player.id) + releasePendingAudioFocusIfUnused() + } } } @@ -741,8 +877,12 @@ class AudioModule : Module() { } playlist.loadInitialPlaylist() playlist.onPlaybackStateChange = { isPlaying -> - if (!isPlaying && shouldReleaseFocus()) { - releaseAudioFocus() + if (!isPlaying) { + if (shouldReleaseFocus()) { + releaseAudioFocus() + } else { + releasePendingAudioFocusIfUnused() + } } } playlists[playlist.id] = playlist @@ -858,7 +998,9 @@ class AudioModule : Module() { Function("pause") { playlist: AudioPlaylist -> runOnMain { + playlist.isPaused = false playlist.ref.pause() + releasePendingAudioFocusIfUnused() } } @@ -899,12 +1041,18 @@ class AudioModule : Module() { Function("remove") { playlist: AudioPlaylist, index: Int -> runOnMain { playlist.remove(index) + if (playlist.trackCount == 0) { + playlist.isPaused = false + releasePendingAudioFocusIfUnused() + } } } Function("clear") { playlist: AudioPlaylist -> runOnMain { playlist.clear() + playlist.isPaused = false + releasePendingAudioFocusIfUnused() } } @@ -929,8 +1077,9 @@ class AudioModule : Module() { Function("destroy") { playlist: AudioPlaylist -> runOnMain { playlist.clearLockScreenControls() + playlists.remove(playlist.id) + releasePendingAudioFocusIfUnused() } - playlists.remove(playlist.id) } } } diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecords.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecords.kt index a1a9e3f04d797e..c3d0bd9af676ca 100644 --- a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecords.kt +++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecords.kt @@ -1,5 +1,6 @@ package expo.modules.audio +import android.media.AudioManager import android.media.MediaRecorder import android.os.Build import expo.modules.kotlin.records.Field @@ -118,8 +119,16 @@ class AudioLockScreenOptions( enum class InterruptionMode(val value: String) : Enumerable { DO_NOT_MIX("doNotMix"), + DO_NOT_MIX_PERSISTENT("doNotMixPersistent"), DUCK_OTHERS("duckOthers"), - MIX_WITH_OTHERS("mixWithOthers") + MIX_WITH_OTHERS("mixWithOthers"); + + fun toAudioFocusGain(): Int? = when (this) { + DO_NOT_MIX_PERSISTENT -> AudioManager.AUDIOFOCUS_GAIN + DO_NOT_MIX -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + DUCK_OTHERS -> AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + MIX_WITH_OTHERS -> null + } } @OptimizedRecord diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/Playable.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/Playable.kt index a67e7859383067..f1b72bdbdebd17 100644 --- a/packages/expo-audio/android/src/main/java/expo/modules/audio/Playable.kt +++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/Playable.kt @@ -30,15 +30,17 @@ interface Playable { fun seekTo(seconds: Double) = player.seekTo((seconds * 1000L).toLong()) - fun setVolume(volume: Float?) = appContext?.mainQueue?.launch { + fun setVolume(volume: Float?, rememberVolume: Boolean = true) = appContext?.mainQueue?.launch { val boundedVolume = volume?.coerceIn(0f, 1f) ?: 1f if (isMuted) { - if (boundedVolume > 0f) { + if (rememberVolume && boundedVolume > 0f) { previousVolume = boundedVolume } player.volume = 0f } else { - previousVolume = boundedVolume + if (rememberVolume) { + previousVolume = boundedVolume + } player.volume = boundedVolume } } diff --git a/packages/expo-audio/ios/AudioModule.swift b/packages/expo-audio/ios/AudioModule.swift index c6306b0196e3d4..c8972661635a70 100644 --- a/packages/expo-audio/ios/AudioModule.swift +++ b/packages/expo-audio/ios/AudioModule.swift @@ -659,7 +659,7 @@ public class AudioModule: Module { case .duckOthers: playerVolumes[playable.id] = playable.volume playable.volume *= 0.5 - case .doNotMix, .mixWithOthers: + case .doNotMix, .doNotMixPersistent, .mixWithOthers: playable.pause() } } @@ -748,7 +748,7 @@ public class AudioModule: Module { if let originalVolume = playerVolumes[playable.id] { playable.volume = originalVolume } - case .doNotMix, .mixWithOthers: + case .doNotMix, .doNotMixPersistent, .mixWithOthers: playable.resumePlayback() } } @@ -820,7 +820,7 @@ public class AudioModule: Module { do { try sessionQueue.sync { - try AVAudioSession.sharedInstance().setActive(isActive, options: isActive ? [] : [.notifyOthersOnDeactivation]) + try AVAudioSession.sharedInstance().setActive(isActive, options: activationOptions(isActive: isActive)) self.sessionIsActive = isActive } } catch { @@ -856,7 +856,7 @@ public class AudioModule: Module { #endif if !mode.playsInSilentMode { - if mode.interruptionMode == .doNotMix { + if mode.interruptionMode.preventsMixing { category = .soloAmbient } else { category = .ambient @@ -867,7 +867,7 @@ public class AudioModule: Module { var categoryOptions: AVAudioSession.CategoryOptions = [] switch mode.interruptionMode { - case .doNotMix: + case .doNotMix, .doNotMixPersistent: break case .duckOthers: categoryOptions.insert(.duckOthers) @@ -915,6 +915,14 @@ public class AudioModule: Module { } } + private func activationOptions(isActive: Bool) -> AVAudioSession.SetActiveOptions { + guard !isActive, interruptionMode.shouldNotifyOthersOnDeactivation else { + return [] + } + + return [.notifyOthersOnDeactivation] + } + private func deactivateSession() { sessionQueue.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in guard let self, self.sessionIsActive, !self.isSessionInUse else { @@ -927,7 +935,7 @@ public class AudioModule: Module { @discardableResult private func applySessionActive(_ isActive: Bool) -> Bool { do { - try AVAudioSession.sharedInstance().setActive(isActive, options: isActive ? [] : [.notifyOthersOnDeactivation]) + try AVAudioSession.sharedInstance().setActive(isActive, options: activationOptions(isActive: isActive)) sessionIsActive = isActive return true } catch { diff --git a/packages/expo-audio/ios/AudioRecords.swift b/packages/expo-audio/ios/AudioRecords.swift index ad24242d42dec1..d331e804fab76a 100644 --- a/packages/expo-audio/ios/AudioRecords.swift +++ b/packages/expo-audio/ios/AudioRecords.swift @@ -12,7 +12,16 @@ struct AudioMode: Record { enum InterruptionMode: String, Enumerable { case mixWithOthers case doNotMix + case doNotMixPersistent case duckOthers + + var preventsMixing: Bool { + self == .doNotMix || self == .doNotMixPersistent + } + + var shouldNotifyOthersOnDeactivation: Bool { + self != .doNotMixPersistent + } } enum LoopMode: String, Enumerable { diff --git a/packages/expo-audio/src/Audio.types.ts b/packages/expo-audio/src/Audio.types.ts index aca0fd4b7951d4..b07bd1c5040f33 100644 --- a/packages/expo-audio/src/Audio.types.ts +++ b/packages/expo-audio/src/Audio.types.ts @@ -599,12 +599,18 @@ export type AudioMode = { /** * Determines how the audio session interacts with other audio sessions. * - * - `'doNotMix'`: Requests exclusive audio focus. Other apps will pause their audio. + * - `'doNotMix'`: Requests transient exclusive audio focus. Other apps will pause their audio + * and may resume when your app no longer needs focus. + * - `'doNotMixPersistent'`: Requests persistent exclusive audio focus. Other apps will pause + * their audio and should not automatically resume when your app no longer needs focus. * - `'duckOthers'`: Requests audio focus with ducking. Other apps lower their volume but continue playing. * - `'mixWithOthers'`: Audio plays alongside other apps without interrupting them. * On Android, this means no audio focus is requested. Best suited for sound effects, * UI feedback, or short audio clips. * + * > **Note:** `doNotMixPersistent` does not keep the audio session active after playback + * > stops by itself. Set **keepAudioSessionActive** to `true` to keep it active. + * * @default 'mixWithOthers' */ interruptionMode: InterruptionMode; @@ -651,7 +657,10 @@ export type AudioMode = { * * Controls how your app's audio interacts with other apps' audio. * - * - `'doNotMix'`: Requests exclusive audio focus. Other apps will pause their audio. + * - `'doNotMix'`: Requests transient exclusive audio focus. Other apps will pause their audio + * and may resume when your app no longer needs focus. + * - `'doNotMixPersistent'`: Requests persistent exclusive audio focus. Other apps will pause + * their audio and should not automatically resume when your app no longer needs focus. * - `'duckOthers'`: Requests audio focus with ducking. Other apps lower their volume but continue playing. * - `'mixWithOthers'`: Audio plays alongside other apps without interrupting them. * @@ -659,11 +668,12 @@ export type AudioMode = { * UI feedback, or short audio clips. Note that on Android your app won't receive * audio focus loss callbacks (for example, during phone calls) when using this mode. * - * > **Note:** When using `setActiveForLockScreen`, this must be set to `doNotMix`. + * > **Note:** When using `setActiveForLockScreen`, this must be set to `doNotMix` or + * > `doNotMixPersistent`. * * @default 'mixWithOthers' */ -export type InterruptionMode = 'mixWithOthers' | 'doNotMix' | 'duckOthers'; +export type InterruptionMode = 'mixWithOthers' | 'doNotMix' | 'doNotMixPersistent' | 'duckOthers'; /** * @deprecated Use `InterruptionMode` instead, which now works on both platforms. diff --git a/packages/expo-audio/src/AudioModule.types.ts b/packages/expo-audio/src/AudioModule.types.ts index 0f74e9aefe59ab..9f63a9c2574136 100644 --- a/packages/expo-audio/src/AudioModule.types.ts +++ b/packages/expo-audio/src/AudioModule.types.ts @@ -213,7 +213,7 @@ export declare class AudioPlayer extends SharedObject { * Sets or removes this audio player as the active player for lock screen controls. * Only one player can control the lock screen at a time. * - * > **Note:** For lock screen controls to work correctly, [`interruptionMode`](#interruptionmode) must be set to `doNotMix` using [`setAudioModeAsync`](#audiosetaudiomodeasyncmode). + * > **Note:** For lock screen controls to work correctly, [`interruptionMode`](#interruptionmode) must be set to `doNotMix` or `doNotMixPersistent` using [`setAudioModeAsync`](#audiosetaudiomodeasyncmode). * > Without this, the OS might not associate lock screen controls with your player. * * @param active Whether this player should be active for lock screen controls. @@ -536,7 +536,7 @@ export declare class AudioPlaylist extends SharedObject { * Sets or removes this audio playlist as the active playlist for lock screen controls. * Only one audio player or playlist can control the lock screen at a time. * - * > **Note:** For lock screen controls to work correctly, [`interruptionMode`](#interruptionmode) must be set to `doNotMix` using [`setAudioModeAsync`](#audiosetaudiomodeasyncmode). + * > **Note:** For lock screen controls to work correctly, [`interruptionMode`](#interruptionmode) must be set to `doNotMix` or `doNotMixPersistent` using [`setAudioModeAsync`](#audiosetaudiomodeasyncmode). * > Without this, the OS might not associate lock screen controls with your playlist. * * @param active Whether this playlist should be active for lock screen controls. diff --git a/packages/expo-image/CHANGELOG.md b/packages/expo-image/CHANGELOG.md index 038fc109fccc31..ce3274d868a996 100644 --- a/packages/expo-image/CHANGELOG.md +++ b/packages/expo-image/CHANGELOG.md @@ -9,6 +9,7 @@ - Added an `imageLoaded` module event emitted with the decoded pixel size from every load path. ([#47337](https://github.com/expo/expo/pull/47337) by [@Ubax](https://github.com/Ubax)) - add expo-observe integration ([#47145](https://github.com/expo/expo/pull/47145) by [@Ubax](https://github.com/Ubax)) - [iOS][Android] Added a `skipOnCacheHit` field to `transition` that skips the fade the first time a cached image appears (`'memory'` for memory-cache hits, `'all'` for any cache hit), so already-loaded images don't re-animate on mount, tab change, or when scrolling back into view. A transition from a `source` change still plays. ([#48181](https://github.com/expo/expo/pull/48181) by [@janicduplessis](https://github.com/janicduplessis)) +- Added an `includeUrlParams` option to the expo-observe integration; reported image URLs now have their query string and fragment removed unless it is enabled, basic-auth credentials are always removed, and only `http(s)`, `file`, and `android.resource` URLs are reported. ([#49083](https://github.com/expo/expo/pull/49083) by [@tsapeta](https://github.com/tsapeta)) ### 🐛 Bug fixes diff --git a/packages/expo-image/src/__tests__/observe.test.ts b/packages/expo-image/src/__tests__/observe.test.ts index ed6342b5540985..134965fd6636a5 100644 --- a/packages/expo-image/src/__tests__/observe.test.ts +++ b/packages/expo-image/src/__tests__/observe.test.ts @@ -101,6 +101,7 @@ function state( over: Partial<{ enabled: boolean; threshold: number; + includeUrlParams: boolean; reported: Set; subscription: { remove: () => void } | null; appMetrics: FakeAppMetrics | null; @@ -110,6 +111,7 @@ function state( return { enabled: true, threshold: 2, + includeUrlParams: false, reported: new Set(), subscription: null, appMetrics: makeAppMetrics(), @@ -221,6 +223,194 @@ describe('reportIfOversized', () => { expect(() => reportIfOversized(state({ appMetrics: null }), image(1000))).not.toThrow(); }); + it('strips the query and fragment from the reported url by default', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized( + state({ appMetrics }), + image(1000, 1000, 'https://example.com/a.png?token=secret#frag') + ); + + expect(appMetrics.logEvent).toHaveBeenCalledTimes(1); + expect(appMetrics.logEvent.mock.calls[0][1].attributes.url).toBe('https://example.com/a.png'); + }); + + it('dedups on the stripped url so signed variants of one image report once', () => { + const appMetrics = makeAppMetrics(); + const s = state({ appMetrics }); + + reportIfOversized(s, image(1000, 1000, 'https://example.com/a.png?token=1')); + reportIfOversized(s, image(1000, 1000, 'https://example.com/a.png?token=2')); + + expect(appMetrics.logEvent).toHaveBeenCalledTimes(1); + }); + + it('reports the full url when includeUrlParams is enabled', () => { + const appMetrics = makeAppMetrics(); + const s = state({ appMetrics, includeUrlParams: true }); + + reportIfOversized(s, image(1000, 1000, 'https://example.com/a.png?w=4000')); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.url).toBe( + 'https://example.com/a.png?w=4000' + ); + }); + + it('dedups on the full url when includeUrlParams is enabled', () => { + const appMetrics = makeAppMetrics(); + const s = state({ appMetrics, includeUrlParams: true }); + + reportIfOversized(s, image(1000, 1000, 'https://example.com/a.png?id=1')); + reportIfOversized(s, image(1000, 1000, 'https://example.com/a.png?id=2')); + + expect(appMetrics.logEvent).toHaveBeenCalledTimes(2); + }); + + it('reports file: urls', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized(state({ appMetrics }), image(1000, 1000, 'file:///var/app/assets/hero.png')); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.url).toBe( + 'file:///var/app/assets/hero.png' + ); + }); + + it('reports bundled android.resource: urls', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized( + state({ appMetrics }), + image(1000, 1000, 'android.resource://com.example.app/2131165280') + ); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.url).toBe( + 'android.resource://com.example.app/2131165280' + ); + }); + + it('never reports urls outside the http, https, file, and android.resource schemes', () => { + const appMetrics = makeAppMetrics(); + const s = state({ appMetrics }); + + reportIfOversized(s, image(1000, 1000, 'ph://ED7AC36B-A150-4C38-BB8C-B6D696F4F2ED/L0/001')); + reportIfOversized(s, image(1000, 1000, 'content://media/external/images/media/12')); + + expect(appMetrics.logEvent).not.toHaveBeenCalled(); + }); + + it('flags a query-stripped url with urlSanitized', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized( + state({ appMetrics }), + image(1000, 1000, 'https://example.com/a.png?token=1') + ); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.urlSanitized).toBe(true); + }); + + it('flags a credential-stripped url with urlSanitized even when includeUrlParams is enabled', () => { + const appMetrics = makeAppMetrics(); + const s = state({ appMetrics, includeUrlParams: true }); + + reportIfOversized(s, image(1000, 1000, 'https://user:pass@example.com/a.png?w=4000')); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.urlSanitized).toBe(true); + }); + + it('reports urlSanitized as false when the url is unchanged', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized(state({ appMetrics }), image(1000, 1000, 'https://example.com/a.png')); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.urlSanitized).toBe(false); + }); + + it('never reports data: urls', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized(state({ appMetrics }), image(1000, 1000, 'data:image/png;base64,aGVsbG8=')); + + expect(appMetrics.logEvent).not.toHaveBeenCalled(); + }); + + it('never reports data: urls even when includeUrlParams is enabled', () => { + const appMetrics = makeAppMetrics(); + const s = state({ appMetrics, includeUrlParams: true }); + + reportIfOversized(s, image(1000, 1000, 'data:image/png;base64,aGVsbG8=')); + + expect(appMetrics.logEvent).not.toHaveBeenCalled(); + }); + + it('strips basic-auth credentials from the reported url', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized( + state({ appMetrics }), + image(1000, 1000, 'https://user:pass@example.com/a.png?token=1') + ); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.url).toBe('https://example.com/a.png'); + }); + + it('strips credentials that contain an unencoded @', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized( + state({ appMetrics }), + image(1000, 1000, 'https://user:pa@ss@example.com/a.png') + ); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.url).toBe('https://example.com/a.png'); + }); + + it('strips credentials even when includeUrlParams is enabled', () => { + const appMetrics = makeAppMetrics(); + const s = state({ appMetrics, includeUrlParams: true }); + + reportIfOversized(s, image(1000, 1000, 'https://user:pass@example.com/a.png?w=4000')); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.url).toBe( + 'https://example.com/a.png?w=4000' + ); + }); + + it('keeps an @ that is part of the path or query', () => { + const appMetrics = makeAppMetrics(); + const s = state({ appMetrics, includeUrlParams: true }); + + reportIfOversized(s, image(1000, 1000, 'https://example.com/img@2x.png')); + reportIfOversized(s, image(1000, 1000, 'https://example.com?email=a@b.com')); + + expect(appMetrics.logEvent.mock.calls[0][1].attributes.url).toBe( + 'https://example.com/img@2x.png' + ); + expect(appMetrics.logEvent.mock.calls[1][1].attributes.url).toBe( + 'https://example.com/?email=a@b.com' + ); + }); + + it('reports urls in normalized form without flagging them as sanitized', () => { + const appMetrics = makeAppMetrics(); + + reportIfOversized(state({ appMetrics }), image(1000, 1000, 'HTTPS://EXAMPLE.com/a.png')); + + const attributes = appMetrics.logEvent.mock.calls[0][1].attributes; + expect(attributes.url).toBe('https://example.com/a.png'); + expect(attributes.urlSanitized).toBe(false); + }); + + it('never reports an unparseable url', () => { + const appMetrics = makeAppMetrics(); + + // The scheme-less name React Native gives a bundled asset on Android in release builds. + reportIfOversized(state({ appMetrics }), image(1000, 1000, 'src_assets_hero')); + + expect(appMetrics.logEvent).not.toHaveBeenCalled(); + }); + it('does not throw when logEvent fails', () => { const appMetrics: FakeAppMetrics = { logEvent: jest.fn(() => { @@ -276,6 +466,24 @@ describe('activate', () => { expect(s.threshold).toBe(1.5); }); + it('keeps url params disabled by default', () => { + const s = state({ enabled: false, includeUrlParams: true }); + + activate(s, { 'expo-image': true }); + expect(s.includeUrlParams).toBe(false); + + activate(s, { 'expo-image': { oversizeThreshold: 3 } }); + expect(s.includeUrlParams).toBe(false); + }); + + it('reads includeUrlParams from an object config', () => { + const s = state({ enabled: false }); + + activate(s, { 'expo-image': { includeUrlParams: true } }); + + expect(s.includeUrlParams).toBe(true); + }); + it('does not enable or subscribe when the config is absent', () => { const imageModule = makeImageModule(); const s = state({ enabled: false, imageModule }); diff --git a/packages/expo-image/src/observe.ts b/packages/expo-image/src/observe.ts index 9ab4805200a710..adb00581cf71da 100644 --- a/packages/expo-image/src/observe.ts +++ b/packages/expo-image/src/observe.ts @@ -27,6 +27,19 @@ export type ExpoImageIntegrationConfig = { * @default 1.5 */ oversizeThreshold?: number; + /** + * Whether reported events include the image URL's query string and fragment. By default the URL + * is truncated at them before it leaves the device, because query parameters often carry + * sensitive values such as signing tokens or API keys. Enable this only when your image URLs + * are safe to send off-device in full. Regardless of this setting, basic-auth credentials are + * always removed from the URL, and only `http(s)`, `file`, and `android.resource` URLs are + * reported (other schemes, such as `data:` or `ph://`, never leave the device). URLs are + * reported in normalized (WHATWG) form, and the event's `urlSanitized` attribute tells whether + * the URL was modified beyond that. + * + * @default false + */ + includeUrlParams?: boolean; }; const DEFAULT_OVERSIZE_THRESHOLD = 1.5; @@ -38,7 +51,9 @@ let initialized = false; export type IntegrationState = { enabled: boolean; threshold: number; - // URLs already reported under the current configuration. Only oversized images are added. + includeUrlParams: boolean; + // URLs already reported under the current configuration, as they were reported (so without + // query and fragment unless `includeUrlParams` is set). Only oversized images are added. reported: Set; // Subscription to the native `imageLoaded` event, held only while the integration is enabled. subscription: { remove: () => void } | null; @@ -61,16 +76,49 @@ export type LoadedImage = { pixelRatio: number; }; +// Only remote images, local files, and bundled Android resources are reported: those URLs +// identify developer-owned content that the developer can act on. Every other scheme fails safe, +// because it carries device-local or user-library content (the whole payload for `data:`, a +// stable personal-photo identifier for `ph://` and `content://`). +const REPORTABLE_PROTOCOLS = new Set(['http:', 'https:', 'file:', 'android.resource:']); + +// Prepares a loaded image's URL for sending off-device, or returns `null` when the image must not +// be reported at all (a disallowed scheme, or a string the WHATWG parser rejects). Basic-auth +// credentials are always removed, and the query string and fragment too unless `includeUrlParams` +// opts in, so values like signing tokens or API keys never leave the device. The returned URL is +// in normalized (WHATWG) form; `sanitized` is true only when something was removed, not when +// normalization alone changed the string. +function sanitizeUrl( + rawUrl: string, + includeUrlParams: boolean +): { url: string; sanitized: boolean } | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + if (!REPORTABLE_PROTOCOLS.has(url.protocol)) { + return null; + } + const normalized = url.toString(); + url.username = ''; + url.password = ''; + if (!includeUrlParams) { + url.search = ''; + url.hash = ''; + } + const result = url.toString(); + return { url: result, sanitized: result !== normalized }; +} + // Exported for testing purposes only. export function reportIfOversized(state: IntegrationState, image: LoadedImage): void { if (!state.enabled || !state.appMetrics) { return; } - const { url, width, height, screenWidth, screenHeight, pixelRatio } = image; - if (!url || !(width > 0) || !(height > 0)) { - return; - } - if (state.reported.has(url)) { + const { width, height, screenWidth, screenHeight, pixelRatio } = image; + if (!image.url || !(width > 0) || !(height > 0)) { return; } // Screen area is in points; the decoded image is in pixels, so convert with pixelRatio² to get @@ -79,6 +127,18 @@ export function reportIfOversized(state: IntegrationState, image: LoadedImage): if (!(budget > 0) || width * height <= budget * state.threshold) { return; } + // Sanitizing after the size check keeps the common per-load path cheap: only oversized images + // pay for the URL parse. + const sanitizedUrl = sanitizeUrl(image.url, state.includeUrlParams); + if (!sanitizedUrl) { + return; + } + // Deduping on the reported form also collapses variants of one image that differ only in their + // query parameters (such as rotating signed URLs) into a single event. + const url = sanitizedUrl.url; + if (state.reported.has(url)) { + return; + } state.reported.add(url); try { state.appMetrics.logEvent('expo-image.oversized', { @@ -87,6 +147,8 @@ export function reportIfOversized(state: IntegrationState, image: LoadedImage): body: `Image loaded at ${width}×${height}px is far larger than this device's screen (${screenWidth}×${screenHeight}pt @${pixelRatio}x). Constrain it with the maxWidth/maxHeight load options.`, attributes: { url, + // True when sanitization changed the URL; WHATWG normalization alone does not count. + urlSanitized: sanitizedUrl.sanitized, imageWidth: width, imageHeight: height, screenWidth, @@ -122,11 +184,10 @@ export function activate( handle = handleImageLoaded ): void { const config = integrations['expo-image']; + const configObject = typeof config === 'object' && config !== null ? config : {}; state.enabled = !!config; - state.threshold = - typeof config === 'object' && config !== null - ? (config.oversizeThreshold ?? DEFAULT_OVERSIZE_THRESHOLD) - : DEFAULT_OVERSIZE_THRESHOLD; + state.threshold = configObject.oversizeThreshold ?? DEFAULT_OVERSIZE_THRESHOLD; + state.includeUrlParams = configObject.includeUrlParams ?? false; // A new configure may change the threshold (or enable the integration), so start a fresh dedup // set: images already reported under the previous settings become eligible to report again. state.reported = new Set(); @@ -151,6 +212,7 @@ export function initObserveIntegrationIfNeededImpl( const state: IntegrationState = { enabled: false, threshold: DEFAULT_OVERSIZE_THRESHOLD, + includeUrlParams: false, reported: new Set(), subscription: null, appMetrics: requireOptionalNativeModule('ExpoAppMetrics'), diff --git a/packages/expo-location/CHANGELOG.md b/packages/expo-location/CHANGELOG.md index 11077a93e8b5ec..b40b955ee4e240 100644 --- a/packages/expo-location/CHANGELOG.md +++ b/packages/expo-location/CHANGELOG.md @@ -10,6 +10,7 @@ ### 🐛 Bug fixes +- [iOS] Add `scope` and `accuracy` under `ios` to the responses from `getBackgroundPermissionsAsync` and `requestBackgroundPermissionsAsync`, matching the `LocationPermissionResponse` type. ([#48926](https://github.com/expo/expo/pull/48926) by [@vonovak](https://github.com/vonovak)) - [Android] Fix `timeInterval` and `distanceInterval` being ignored for background location updates. ([#46788](https://github.com/expo/expo/issues/46788) by [@doshisunny](https://github.com/doshisunny)) - [iOS] Fix incorrect default value for `pausesUpdatesAutomatically` to match docs. ([#47008](https://github.com/expo/expo/pull/47008) by [@Ignigena](https://github.com/Ignigena)) - [Android] Fix leaking watches ([#48294](https://github.com/expo/expo/pull/48294) by [@Wenszel](https://github.com/Wenszel)) diff --git a/packages/expo-location/ios/Requesters/EXBackgroundLocationPermissionRequester.m b/packages/expo-location/ios/Requesters/EXBackgroundLocationPermissionRequester.m index aa54f8c464fa93..9db46ffa37792a 100644 --- a/packages/expo-location/ios/Requesters/EXBackgroundLocationPermissionRequester.m +++ b/packages/expo-location/ios/Requesters/EXBackgroundLocationPermissionRequester.m @@ -151,7 +151,19 @@ - (NSDictionary *)parsePermissions:(CLAuthorizationStatus)systemStatus } } - return @{ @"status": @(status), @"scope": @(systemStatus == kCLAuthorizationStatusAuthorizedWhenInUse ? "whenInUse" : systemStatus == kCLAuthorizationStatusAuthorizedAlways ? "always" : "none"), @"accuracy": [self accuracyAuthorizationString] }; + NSString *scope = @(systemStatus == kCLAuthorizationStatusAuthorizedWhenInUse ? "whenInUse" : systemStatus == kCLAuthorizationStatusAuthorizedAlways ? "always" : "none"); + NSString *accuracy = [self accuracyAuthorizationString]; + + return @{ @"status": @(status), + @"ios": @{ + @"scope": scope, + @"accuracy": accuracy + }, + // Keep these long-standing fields during the migration to the platform-specific response shape. + // TODO: Remove the top-level `scope` and `accuracy` fields after SDK 59. + @"scope": scope, + @"accuracy": accuracy + }; } @end diff --git a/packages/expo-location/src/Location.ts b/packages/expo-location/src/Location.ts index 390beb479c8116..4a113d5dd995e4 100644 --- a/packages/expo-location/src/Location.ts +++ b/packages/expo-location/src/Location.ts @@ -297,9 +297,9 @@ export const useForegroundPermissions = createPermissionHook({ // @needsAudit /** * Checks user's permissions for accessing location while the app is in the background. - * @return A promise that fulfills with an object of type [`PermissionResponse`](#permissionresponse). + * @return A promise that fulfills with an object of type [`LocationPermissionResponse`](#locationpermissionresponse). */ -export async function getBackgroundPermissionsAsync(): Promise { +export async function getBackgroundPermissionsAsync(): Promise { return await ExpoLocation.getBackgroundPermissionsAsync(); } @@ -311,9 +311,9 @@ export async function getBackgroundPermissionsAsync(): Promise __Note__: Foreground permissions should be granted before asking for the background permissions * (your app can't obtain background permission without foreground permission). - * @return A promise that fulfills with an object of type [`PermissionResponse`](#permissionresponse). + * @return A promise that fulfills with an object of type [`LocationPermissionResponse`](#locationpermissionresponse). */ -export async function requestBackgroundPermissionsAsync(): Promise { +export async function requestBackgroundPermissionsAsync(): Promise { return await ExpoLocation.requestBackgroundPermissionsAsync(); } diff --git a/packages/expo-modules-autolinking/CHANGELOG.md b/packages/expo-modules-autolinking/CHANGELOG.md index fa86a5ff454d2f..15633194034a04 100644 --- a/packages/expo-modules-autolinking/CHANGELOG.md +++ b/packages/expo-modules-autolinking/CHANGELOG.md @@ -21,6 +21,7 @@ - Fixed unsorted autolinking result and introduced unstable fingerprint. ([#48629](https://github.com/expo/expo/pull/48629) by [@kudo](https://github.com/kudo)) - [iOS] Stop discarding xcconfig changes made by other `post_install` hooks, which broke builds against React Native nightlies with `include of non-modular header inside framework module 'React.…'`. ([#49038](https://github.com/expo/expo/pull/49038) by [@kudo](https://github.com/kudo)) - [iOS] Fix `'React/RCTBridge.h' file not found` with `ios.useFrameworks: "dynamic"` by reverting the dynamic-framework linkage guard from [#47500](https://github.com/expo/expo/pull/47500). Under `:dynamic` linkage every pod target is already a dynamic framework, so the guard skipped the whole `USE_FRAMEWORKS` downgrade. `@rnmapbox/maps` 10.3.2 no longer needs the guard because it skips its own dynamic flip when precompiled modules are enabled. ([#48869](https://github.com/expo/expo/pull/48869) by [@kudo](https://github.com/kudo)) +- [iOS] Pull a 3rd-party pod's prebuilt XCFramework to source when a dependent 3rd-party pod builds from source, fixing `'worklets/Compat/StableApi.h' file not found`. ([#49147](https://github.com/expo/expo/pull/49147) by [@chrfalch](https://github.com/chrfalch)) ### 💡 Others diff --git a/packages/expo-modules-autolinking/scripts/ios/precompiled_modules.rb b/packages/expo-modules-autolinking/scripts/ios/precompiled_modules.rb index 9c5a4651d88a57..f3261010119f78 100644 --- a/packages/expo-modules-autolinking/scripts/ios/precompiled_modules.rb +++ b/packages/expo-modules-autolinking/scripts/ios/precompiled_modules.rb @@ -72,6 +72,10 @@ module PrecompiledModules # so it is not resolved through the Expo precompiled tarball pipeline. CUSTOM_XCFRAMEWORK_DEPENDENCIES = %w[ExpoModulesJSI].freeze + # Unavailability reasons where the pod's own artifact is fine and an interdependent + # pod pulled it to source. The expected-tarball hint is misleading for these. + CASCADED_UNAVAILABLE_REASONS = %i[dependency_unavailable dependent_unavailable].freeze + # Module-level caches (initialized lazily) @pod_lookup_map = nil @repo_root = nil @@ -81,6 +85,7 @@ module PrecompiledModules @hermes_version = nil @claimed_vendored_frameworks = nil # Set — xcframework names already claimed by a prebuilt pod @framework_owner_map = nil # Hash: framework_name -> owning_pod_name + @prebuilt_dependent_pods = nil # Hash: pod_name -> pods declaring it as a dependency @failed_remote_downloads = Set.new @warned_no_prebuilt_react = false @target_platform = nil @@ -130,6 +135,7 @@ def configure(target_platform: nil, build_from_source: nil) def build_from_source=(patterns) @build_from_source_patterns = (patterns || []).map { |p| Regexp.new("^#{p}$") } + @prebuilt_dependent_pods = nil @status_cache = {} end @@ -142,6 +148,7 @@ def target_platform=(platform) @claimed_vendored_frameworks = nil @framework_owner_map = nil @xcframework_slice_cache = nil + @prebuilt_dependent_pods = nil @status_cache = {} end @@ -638,7 +645,7 @@ def try_link_with_prebuilt_xcframework(spec) def patch_spec_for_prebuilt(spec) resolution = resolve_prebuilt_status(spec.name) unless resolution[:available] - log_linking_status(spec.name, false, resolution) if resolution[:reason] == :dependency_unavailable + log_linking_status(spec.name, false, resolution) if CASCADED_UNAVAILABLE_REASONS.include?(resolution[:reason]) return spec end @@ -1794,6 +1801,24 @@ def prebuilt_dependency_pods(external_dependencies) end.uniq end + # Reverse of `prebuilt_dependency_pods` over 3rd-party pods: maps a pod to the + # pods that declare it as a dependency. + # + # @return [Hash>] Pod name to the pods depending on it + def prebuilt_dependent_pods + @prebuilt_dependent_pods ||= begin + dependents = {} + pod_lookup_map.each do |pod_name, info| + next unless info[:type] == :external + (info[:prebuilt_dependency_pods] || []).each do |dep_name| + next unless pod_lookup_map.dig(dep_name, :type) == :external + (dependents[dep_name] ||= []) << pod_name + end + end + dependents + end + end + # Resolves the codegen module name. For external packages, prefers codegenConfig.name # from the installed package.json over spm.config.json's codegenName. def resolve_codegen_name(product, pod_name, npm_package, type, repo_root) @@ -1951,11 +1976,15 @@ def external_prebuilt_pods(project_directory) pod_lookup_map.each do |pod_name, info| next unless info[:type] == :external - unless has_prebuilt_xcframework?(pod_name) - product_name = info[:product_name] || pod_name - expected = File.join(info[:build_output_dir], build_flavor, 'xcframeworks', "#{product_name}.tar.gz") - Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{"#{pod_name}: prebuilt xcframework unavailable; building from source".yellow}" - Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{gray(" Expected tarball: #{expected}")}" + resolution = resolve_prebuilt_status(pod_name) + unless resolution[:available] + reason = format_prebuilt_unavailable_reason(resolution) + Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{"#{pod_name}: building from source (#{reason})".yellow}" + unless CASCADED_UNAVAILABLE_REASONS.include?(resolution[:reason]) + product_name = info[:product_name] || pod_name + expected = File.join(info[:build_output_dir], build_flavor, 'xcframeworks', "#{product_name}.tar.gz") + Pod::UI.puts "#{'[Expo-precompiled] '.blue}#{gray(" Expected tarball: #{expected}")}" + end next end @@ -2082,7 +2111,8 @@ def resolve_own_prebuilt_info(pod_name) end # A pod may use a prebuilt xcframework only when its own prebuilt artifact - # exists and every local Expo dependency also uses prebuilt. + # exists and every pod it is interdependent with also uses prebuilt — in either + # direction, so a set of interdependent pods is all prebuilt or all from source. def resolve_prebuilt_status(pod_name, visiting = Set.new) return _resolve_prebuilt_status_uncached(pod_name, visiting) unless visiting.empty? @status_cache[pod_name] ||= _resolve_prebuilt_status_uncached(pod_name, visiting) @@ -2111,8 +2141,22 @@ def _resolve_prebuilt_status_uncached(pod_name, visiting) available: false, reason: :dependency_unavailable, dependency: dep_name, - dependency_reason: dep_resolution[:reason], - dependency_path: dep_resolution[:path] + dependency_resolution: dep_resolution + } + end + + # Unavailability propagates to dependencies too: a source-built dependent + # includes its dependency's headers from `Pods/Headers/Public/`, which + # CocoaPods only populates while the dependency builds from source. + prebuilt_dependent_pods.fetch(pod_name, []).each do |dependent_name| + dependent_resolution = resolve_prebuilt_status(dependent_name, next_visiting) + next if dependent_resolution[:available] + + return { + available: false, + reason: :dependent_unavailable, + dependent: dependent_name, + dependent_resolution: dependent_resolution } end @@ -2530,8 +2574,11 @@ def format_prebuilt_unavailable_reason(info) when :missing_platform_slice "prebuilt xcframework does not contain a slice for #{@target_platform}" when :dependency_unavailable - reason = format_prebuilt_unavailable_reason(reason: info[:dependency_reason], path: info[:dependency_path]) + reason = format_prebuilt_unavailable_reason(info[:dependency_resolution]) "dependency #{info[:dependency]} is not using prebuilt: #{reason}" + when :dependent_unavailable + reason = format_prebuilt_unavailable_reason(info[:dependent_resolution]) + "dependent #{info[:dependent]} is not using prebuilt: #{reason}" else info[:path] || 'prebuilt unavailable' end diff --git a/packages/expo-observe/CHANGELOG.md b/packages/expo-observe/CHANGELOG.md index 99f1c7d972b3f8..6eafbc0a46d4f8 100644 --- a/packages/expo-observe/CHANGELOG.md +++ b/packages/expo-observe/CHANGELOG.md @@ -17,6 +17,7 @@ ### 💡 Others +- [iOS] Dispatch pending metrics and logs in chunks of 200 and retry HTTP 413 responses with smaller batches. ([#49121](https://github.com/expo/expo/pull/49121) by [@Ubax](https://github.com/Ubax)) - [Android] Retry a dispatch that gets HTTP 413 ([#49016](https://github.com/expo/expo/pull/49016) by [@Ubax](https://github.com/Ubax)) - [Android] Dispatch pending metrics and logs in bounded, oldest-first chunks without replacing active background work. ([#49012](https://github.com/expo/expo/pull/49012) by [@Ubax](https://github.com/Ubax)) - Mark the `AppMetrics` export as deprecated in favor of `Observe`. ([#48901](https://github.com/expo/expo/pull/48901) by [@kadikraman](https://github.com/kadikraman)) diff --git a/packages/expo-observe/ios/DispatchLoop.swift b/packages/expo-observe/ios/DispatchLoop.swift new file mode 100644 index 00000000000000..709a10289d3437 --- /dev/null +++ b/packages/expo-observe/ios/DispatchLoop.swift @@ -0,0 +1,84 @@ +// Copyright 2025-present 650 Industries. All rights reserved. + +import ExpoAppMetrics + +@AppMetricsActor +internal enum DispatchLoop { + internal static let defaultChunkSize = 200 + + internal static func drain( + startCursor: Int64, + chunkSize: Int = defaultChunkSize, + fetchBatch: (_ afterId: Int64, _ limit: Int) throws -> [Row], + rowId: (Row) -> Int64?, + send: (_ rows: [Row]) async throws -> DispatchResult?, + onResult: (_ result: DispatchResult, _ batchCount: Int, _ highestId: Int64) -> Void, + persistCursor: (Int64) -> Void + ) async { + var cursor = startCursor + + dispatchLoop: while !Task.isCancelled { + let fetchedRows: [Row] + do { + fetchedRows = try fetchBatch(cursor, chunkSize) + } catch { + observeLogger.warn("[EAS Observe] Failed to read pending rows: \(error.localizedDescription)") + return + } + guard !fetchedRows.isEmpty else { + return + } + + var rows = fetchedRows + while !Task.isCancelled { + guard let lastRow = rows.last else { + return + } + // A missing id must never rewind the cursor, so fall back to the current one. + let highestId = rowId(lastRow) ?? cursor + let result: DispatchResult? + do { + result = try await send(rows) + } catch { + observeLogger.warn("[EAS Observe] Failed to assemble or send pending rows: \(error.localizedDescription)") + return + } + + guard let result else { + guard highestId > cursor else { + return + } + cursor = highestId + persistCursor(cursor) + continue dispatchLoop + } + onResult(result, rows.count, highestId) + + switch result { + case .success, .partialSuccess: + // Stop when the batch cannot advance the cursor — continuing would refetch and + // re-send the same rows forever. + guard highestId > cursor else { + return + } + cursor = highestId + persistCursor(cursor) + continue dispatchLoop + case .retryableFailure: + return + case .nonRetryableFailure: + persistCursor(highestId) + return + case .payloadTooLarge: + guard rows.count > 1 else { + persistCursor(highestId) + return + } + // Unlike Android's re-fetch, slicing can re-send rows deleted during this loop, and event + // payloads are rebuilt from the session snapshot available on each attempt. + rows = Array(rows.prefix(max(1, rows.count / 2))) + } + } + } + } +} diff --git a/packages/expo-observe/ios/DispatchUtils.swift b/packages/expo-observe/ios/DispatchUtils.swift index 5fd4b5eebc1e0d..39f5170287ce13 100644 --- a/packages/expo-observe/ios/DispatchUtils.swift +++ b/packages/expo-observe/ios/DispatchUtils.swift @@ -2,7 +2,7 @@ import ExpoModulesCore -/// Outcome of a single dispatch attempt to the OTLP endpoint. Four cases, modeled after the +/// Outcome of a single dispatch attempt to the OTLP endpoint, modeled after the /// OTLP retry guidance (see https://opentelemetry.io/docs/specs/otlp/#otlphttp-response): /// /// - `.success` — server accepted the batch without rejections. @@ -13,6 +13,7 @@ import ExpoModulesCore /// a drop. /// - `.retryableFailure` — transient failure (408/429/502/503/504 or transport error); retry the /// same batch after `retryAfter` seconds or a client-computed backoff. +/// - `.payloadTooLarge` — HTTP 413; retry a smaller batch. /// - `.nonRetryableFailure` — permanent failure (4xx/5xx outside the retryable set, encoding error); /// drop the batch so it can't wedge the loop. /// @@ -23,6 +24,7 @@ internal enum DispatchResult: Equatable, Sendable { case success case partialSuccess(OTPartialSuccess) case retryableFailure(retryAfter: TimeInterval?) + case payloadTooLarge case nonRetryableFailure(reason: String) } @@ -117,6 +119,10 @@ internal enum DispatchUtils { "[EAS Observe] Server responded with \(urlResponse.statusCode) (retryable) and data: " + "\(String(data: responseData, encoding: .utf8) ?? "")" ) + case .payloadTooLarge: + observeLogger.warn( + "[EAS Observe] Server responded with 413 (payload too large); retrying a smaller batch" + ) case .nonRetryableFailure(let reason): observeLogger.warn( "[EAS Observe] Server responded with \(urlResponse.statusCode) (non-retryable, " @@ -126,7 +132,7 @@ internal enum DispatchUtils { return result } - /// Pure classifier that maps an HTTP response into one of three retry outcomes. Extracted + /// Pure classifier that maps an HTTP response into a dispatch outcome. Extracted /// from `sendRequest` so the OTLP-spec rules can be unit-tested without a real network call. /// /// `bodyExcerpt` is invoked lazily, only when the result is `.nonRetryableFailure` and the reason @@ -153,6 +159,10 @@ internal enum DispatchUtils { return .success } + if statusCode == 413 { + return .payloadTooLarge + } + // Retryable per OTLP. switch statusCode { case 429, 502, 503, 504: @@ -226,8 +236,9 @@ internal enum DispatchUtils { /// permanently, so retrying would just produce the same answer; advancing the cursor /// drops the batch so it can't wedge subsequent rounds. This is the acceptance-criterion /// behavior: a 400/403 must not be re-sent on the next cycle. - /// - `.retryableFailure` leaves the cursor at its current value so the next dispatch attempt picks - /// the same rows up again. + /// - `.retryableFailure` and `.payloadTooLarge` leave the cursor at its current value so the same + /// rows can be retried. The dispatch loop makes the one exception for a single-row 413, which it + /// drops directly so that record cannot wedge subsequent dispatches. internal static func nextCursor( for result: DispatchResult, currentCursor: Int64, @@ -236,7 +247,7 @@ internal enum DispatchUtils { switch result { case .success, .partialSuccess, .nonRetryableFailure: return highestId - case .retryableFailure: + case .retryableFailure, .payloadTooLarge: return currentCursor } } @@ -286,8 +297,8 @@ internal enum DispatchUtils { /// gate either already expired (we wouldn't have dispatched otherwise) or was never set /// — either way, a server response that ACCEPTED the bytes (even if it rejected a subset /// server-side) doesn't introduce a new pause. - /// - `.nonRetryableFailure` also resets the counter. A permanent drop isn't a sign that the - /// server is unhealthy and shouldn't pause subsequent rounds. + /// - `.nonRetryableFailure` and `.payloadTooLarge` also reset the counter. Neither indicates a + /// transient server failure that should pause subsequent rounds. /// - `.retryableFailure` increments the counter and sets the gate to `now + delay`, where `delay` /// is the server-supplied `Retry-After` if present, otherwise `backoff(nextCount)`. /// @@ -300,7 +311,7 @@ internal enum DispatchUtils { backoff: (Int) -> TimeInterval ) -> RetryGateState { switch result { - case .success, .partialSuccess, .nonRetryableFailure: + case .success, .partialSuccess, .nonRetryableFailure, .payloadTooLarge: return RetryGateState( dispatchAfterDate: currentState.dispatchAfterDate, consecutiveRetryableFailures: 0 diff --git a/packages/expo-observe/ios/Observability.swift b/packages/expo-observe/ios/Observability.swift index 886a33c0e07510..ae6a61329cba00 100644 --- a/packages/expo-observe/ios/Observability.swift +++ b/packages/expo-observe/ios/Observability.swift @@ -61,6 +61,9 @@ internal struct ObservabilityManager { } private static func dispatchMetrics(shouldDispatch: Bool) async { + guard let endpointUrl = metricsEndpointUrl else { + return + } if retryGateBlocks(metricsRetryGate, signal: "metrics") { return } @@ -68,62 +71,70 @@ internal struct ObservabilityManager { repairMetricCursorIfStale() let cursor = ObserveUserDefaults.lastDispatchedMetricId - let pendingMetrics: [MetricRow] - do { - pendingMetrics = try AppMetrics.getMetrics(afterId: cursor) - } catch { - observeLogger.warn("[EAS Observe] Failed to read pending metrics: \(error.localizedDescription)") - return - } - guard !pendingMetrics.isEmpty, let endpointUrl = metricsEndpointUrl else { - observeLogger.debug("[EAS Observe] No new metrics to dispatch") - return - } - let highestId = pendingMetrics.last?.id ?? cursor if !shouldDispatch { - ObserveUserDefaults.lastDispatchedMetricId = highestId - return - } - let events: [Event] - do { - events = try buildEvents(forMetrics: pendingMetrics) - } catch { - observeLogger.warn("[EAS Observe] Failed to assemble metric events: \(error.localizedDescription)") - return - } - if events.isEmpty { - ObserveUserDefaults.lastDispatchedMetricId = highestId + do { + if let highestId = try AppMetrics.getMaxMetricId() { + ObserveUserDefaults.lastDispatchedMetricId = highestId + } + } catch { + observeLogger.warn("[EAS Observe] Failed to read pending metrics: \(error.localizedDescription)") + } return } - let body = OTRequestBody(resourceMetrics: events.map { $0.toOTEvent(easClientId) }) - let result = await DispatchUtils.sendRequest(to: endpointUrl, body: body) - applyRetryOutcome(result, to: &metricsRetryGate) - ObserveUserDefaults.lastDispatchedMetricId = DispatchUtils.nextCursor( - for: result, - currentCursor: cursor, - highestId: highestId + + await DispatchLoop.drain( + startCursor: cursor, + fetchBatch: { cursor, limit in + let metrics = try AppMetrics.getMetrics(afterId: cursor, limit: limit) + if metrics.isEmpty { + observeLogger.debug("[EAS Observe] No new metrics to dispatch") + } + return metrics + }, + rowId: { $0.id }, + send: { metrics in + let events = try buildEvents(forMetrics: metrics) + guard !events.isEmpty else { + return nil + } + let body = OTRequestBody(resourceMetrics: events.map { $0.toOTEvent(easClientId) }) + return await DispatchUtils.sendRequest(to: endpointUrl, body: body) + }, + onResult: { result, batchCount, highestId in + applyRetryOutcome(result, to: &metricsRetryGate) + switch result { + case .success: + ObserveUserDefaults.lastDispatchDate = Date.now + case .partialSuccess(let partial): + ObserveUserDefaults.lastDispatchDate = Date.now + observeLogger.warn( + "[EAS Observe] Partial success on batch of \(batchCount) metric row(s) past " + + "id \(highestId): server rejected \(partial.rejectedCount) " + + "(\(partial.errorMessage ?? "no error message"))" + ) + case .retryableFailure: + break + case .nonRetryableFailure(let reason): + observeLogger.warn( + "[EAS Observe] Dropping batch of \(batchCount) metric row(s) past id " + + "\(highestId): \(reason)" + ) + case .payloadTooLarge where batchCount == 1: + observeLogger.warn( + "[EAS Observe] Dropping metric row id \(highestId) because it exceeds the server payload limit" + ) + case .payloadTooLarge: + break + } + }, + persistCursor: { ObserveUserDefaults.lastDispatchedMetricId = $0 } ) - switch result { - case .success: - ObserveUserDefaults.lastDispatchDate = Date.now - case .partialSuccess(let partial): - ObserveUserDefaults.lastDispatchDate = Date.now - observeLogger.warn( - "[EAS Observe] Partial success on batch of \(events.count) metric event(s) past " - + "id \(highestId): server rejected \(partial.rejectedCount) " - + "(\(partial.errorMessage ?? "no error message"))" - ) - case .retryableFailure: - break - case .nonRetryableFailure(let reason): - observeLogger.warn( - "[EAS Observe] Dropping batch of \(events.count) metric event(s) past id " - + "\(highestId): \(reason)" - ) - } } private static func dispatchLogs(shouldDispatch: Bool) async { + guard let endpointUrl = logsEndpointUrl else { + return + } if retryGateBlocks(logsRetryGate, signal: "logs") { return } @@ -131,63 +142,68 @@ internal struct ObservabilityManager { repairLogCursorIfStale() let cursor = ObserveUserDefaults.lastDispatchedLogId - let pendingLogs: [LogRow] - do { - pendingLogs = try AppMetrics.getLogs(afterId: cursor) - } catch { - observeLogger.warn("[EAS Observe] Failed to read pending logs: \(error.localizedDescription)") - return - } - guard !pendingLogs.isEmpty, let endpointUrl = logsEndpointUrl else { - observeLogger.debug("[EAS Observe] No new logs to dispatch") - return - } - let highestId = pendingLogs.last?.id ?? cursor if !shouldDispatch { - ObserveUserDefaults.lastDispatchedLogId = highestId - return - } - let events: [Event] - do { - events = try buildEvents(forLogs: pendingLogs) - } catch { - observeLogger.warn("[EAS Observe] Failed to assemble log events: \(error.localizedDescription)") - return - } - let resourceLogs = events.compactMap { event -> OTResourceLogs? in - guard !event.logs.isEmpty else { - return nil + do { + if let highestId = try AppMetrics.getMaxLogId() { + ObserveUserDefaults.lastDispatchedLogId = highestId + } + } catch { + observeLogger.warn("[EAS Observe] Failed to read pending logs: \(error.localizedDescription)") } - return event.toOTResourceLogs(easClientId) - } - if resourceLogs.isEmpty { - ObserveUserDefaults.lastDispatchedLogId = highestId return } - let body = OTLogsRequestBody(resourceLogs: resourceLogs) - let result = await DispatchUtils.sendRequest(to: endpointUrl, body: body) - applyRetryOutcome(result, to: &logsRetryGate) - ObserveUserDefaults.lastDispatchedLogId = DispatchUtils.nextCursor( - for: result, - currentCursor: cursor, - highestId: highestId + + await DispatchLoop.drain( + startCursor: cursor, + fetchBatch: { cursor, limit in + let logs = try AppMetrics.getLogs(afterId: cursor, limit: limit) + if logs.isEmpty { + observeLogger.debug("[EAS Observe] No new logs to dispatch") + } + return logs + }, + rowId: { $0.id }, + send: { logs in + let events = try buildEvents(forLogs: logs) + let resourceLogs = events.compactMap { event -> OTResourceLogs? in + guard !event.logs.isEmpty else { + return nil + } + return event.toOTResourceLogs(easClientId) + } + guard !resourceLogs.isEmpty else { + return nil + } + let body = OTLogsRequestBody(resourceLogs: resourceLogs) + return await DispatchUtils.sendRequest(to: endpointUrl, body: body) + }, + onResult: { result, batchCount, highestId in + applyRetryOutcome(result, to: &logsRetryGate) + switch result { + case .success, .retryableFailure: + ObserveUserDefaults.lastDispatchDate = Date.now + case .partialSuccess(let partial): + ObserveUserDefaults.lastDispatchDate = Date.now + observeLogger.warn( + "[EAS Observe] Partial success on batch of \(batchCount) log row(s) past " + + "id \(highestId): server rejected \(partial.rejectedCount) " + + "(\(partial.errorMessage ?? "no error message"))" + ) + case .nonRetryableFailure(let reason): + observeLogger.warn( + "[EAS Observe] Dropping batch of \(batchCount) log row(s) past id " + + "\(highestId): \(reason)" + ) + case .payloadTooLarge where batchCount == 1: + observeLogger.warn( + "[EAS Observe] Dropping log row id \(highestId) because it exceeds the server payload limit" + ) + case .payloadTooLarge: + break + } + }, + persistCursor: { ObserveUserDefaults.lastDispatchedLogId = $0 } ) - switch result { - case .success, .retryableFailure: - ObserveUserDefaults.lastDispatchDate = Date.now - case .partialSuccess(let partial): - ObserveUserDefaults.lastDispatchDate = Date.now - observeLogger.warn( - "[EAS Observe] Partial success on batch of \(resourceLogs.count) log event(s) past " - + "id \(highestId): server rejected \(partial.rejectedCount) " - + "(\(partial.errorMessage ?? "no error message"))" - ) - case .nonRetryableFailure(let reason): - observeLogger.warn( - "[EAS Observe] Dropping batch of \(resourceLogs.count) log event(s) past id " - + "\(highestId): \(reason)" - ) - } } /// Groups `metrics` by `sessionId`, hydrates the matching session rows, and emits one `Event` per diff --git a/packages/expo-observe/ios/Tests/DispatchLoopTests.swift b/packages/expo-observe/ios/Tests/DispatchLoopTests.swift new file mode 100644 index 00000000000000..37ff74ba5ca405 --- /dev/null +++ b/packages/expo-observe/ios/Tests/DispatchLoopTests.swift @@ -0,0 +1,223 @@ +import ExpoAppMetrics +import Testing + +@testable import ExpoObserve + +@AppMetricsActor +@Suite("DispatchLoop") +struct DispatchLoopTests { + private struct Row { + let id: Int64? + } + + private enum TestError: Error { + case failed + } + + @Test + func `single batch advances to its highest id`() async { + let state = State(rows: rows(1...3), results: [.success]) + + await drain(state) + + #expect(state.sentIds == [[1, 2, 3]]) + #expect(state.persistedCursors == [3]) + } + + @Test + func `drains multiple chunks with the configured fetch limit`() async { + let state = State(rows: rows(1...450), results: [.success, .success, .success]) + + await drain(state) + + #expect(state.sentIds.map(\.count) == [200, 200, 50]) + #expect(state.persistedCursors == [200, 400, 450]) + #expect(state.fetchLimits == [200, 200, 200, 200]) + } + + @Test + func `partial success advances and continues`() async { + let partial = OTPartialSuccess(rejectedDataPoints: 1, rejectedLogRecords: nil, errorMessage: nil) + let state = State(rows: rows(1...3), results: [.partialSuccess(partial), .success], chunkSize: 2) + + await drain(state) + + #expect(state.sentIds == [[1, 2], [3]]) + #expect(state.persistedCursors == [2, 3]) + } + + @Test + func `retryable failure on second batch keeps that cursor and stops`() async { + let state = State( + rows: rows(1...5), + results: [.success, .retryableFailure(retryAfter: nil)], + chunkSize: 2 + ) + + await drain(state) + + #expect(state.sentIds == [[1, 2], [3, 4]]) + #expect(state.persistedCursors == [2]) + } + + @Test + func `non retryable failure drops the batch and stops`() async { + let state = State(rows: rows(1...3), results: [.nonRetryableFailure(reason: "bad")], chunkSize: 2) + + await drain(state) + + #expect(state.sentIds == [[1, 2]]) + #expect(state.persistedCursors == [2]) + } + + @Test + func `empty fetch does not send or persist`() async { + let state = State(rows: [], results: []) + + await drain(state) + + #expect(state.sentIds.isEmpty) + #expect(state.persistedCursors.isEmpty) + } + + @Test + func `fetch error keeps the cursor`() async { + let state = State(rows: rows(1...2), results: [.success]) + state.fetchError = .failed + + await drain(state) + + #expect(state.sentIds.isEmpty) + #expect(state.persistedCursors.isEmpty) + } + + @Test + func `send error keeps the cursor and skips onResult`() async { + let state = State(rows: rows(1...2), results: [.success]) + state.sendError = .failed + + await drain(state) + + #expect(state.persistedCursors.isEmpty) + #expect(state.observedResults.isEmpty) + } + + @Test + func `nil send advances and continues without onResult`() async { + let state = State(rows: rows(1...3), results: [nil, .success], chunkSize: 2) + + await drain(state) + + #expect(state.sentIds == [[1, 2], [3]]) + #expect(state.persistedCursors == [2, 3]) + #expect(state.observedResults.map(\.result) == [.success]) + } + + @Test + func `payload too large halves the batch then resumes full chunks`() async { + let state = State( + rows: rows(1...250), + results: [.payloadTooLarge, .success, .success], + chunkSize: 200 + ) + + await drain(state) + + #expect(state.sentIds.map(\.count) == [200, 100, 150]) + #expect(state.persistedCursors == [100, 250]) + #expect(state.fetchLimits == [200, 200, 200]) + } + + @Test + func `retryable after halving keeps the original cursor`() async { + let state = State( + rows: rows(1...200), + results: [.payloadTooLarge, .retryableFailure(retryAfter: nil)] + ) + + await drain(state) + + #expect(state.sentIds.map(\.count) == [200, 100]) + #expect(state.persistedCursors.isEmpty) + } + + @Test + func `repeated payload too large drops exactly one row`() async { + let state = State(rows: rows(1...200), results: Array(repeating: .payloadTooLarge, count: 9)) + + await drain(state) + + #expect(state.sentIds.map(\.count) == [200, 100, 50, 25, 12, 6, 3, 1]) + #expect(state.persistedCursors == [1]) + #expect(state.observedResults.last?.batchCount == 1) + } + + @Test + func `nil last row id stops without moving the cursor`() async { + let state = State(rows: [Row(id: 1), Row(id: nil)], results: [.success]) + + await drain(state) + + #expect(state.sentIds == [[1, nil]]) + #expect(state.persistedCursors.isEmpty) + } + + @Test + func `single row payload too large drops and stops`() async { + let state = State(rows: rows(1...2), results: [.payloadTooLarge], chunkSize: 1) + + await drain(state) + + #expect(state.sentIds == [[1]]) + #expect(state.persistedCursors == [1]) + #expect(state.fetchLimits == [1]) + } + + private func drain(_ state: State) async { + await DispatchLoop.drain( + startCursor: 0, + chunkSize: state.chunkSize, + fetchBatch: { cursor, limit in + state.fetchLimits.append(limit) + if let error = state.fetchError { + throw error + } + return Array(state.rows.filter { ($0.id ?? .max) > cursor }.prefix(limit)) + }, + rowId: { $0.id }, + send: { batch in + state.sentIds.append(batch.map(\.id)) + if let error = state.sendError { + throw error + } + return state.results.removeFirst() + }, + onResult: { result, batchCount, highestId in + state.observedResults.append((result, batchCount, highestId)) + }, + persistCursor: { state.persistedCursors.append($0) } + ) + } + + private func rows(_ ids: ClosedRange) -> [Row] { + return ids.map { Row(id: Int64($0)) } + } + + private final class State { + let rows: [Row] + var results: [DispatchResult?] + let chunkSize: Int + var fetchError: TestError? + var sendError: TestError? + var fetchLimits: [Int] = [] + var sentIds: [[Int64?]] = [] + var persistedCursors: [Int64] = [] + var observedResults: [(result: DispatchResult, batchCount: Int, highestId: Int64)] = [] + + init(rows: [Row], results: [DispatchResult?], chunkSize: Int = 200) { + self.rows = rows + self.results = results + self.chunkSize = chunkSize + } + } +} diff --git a/packages/expo-observe/ios/Tests/DispatchUtilsNextCursorTests.swift b/packages/expo-observe/ios/Tests/DispatchUtilsNextCursorTests.swift index 2004763f65a19d..1be1e1fcbb02d9 100644 --- a/packages/expo-observe/ios/Tests/DispatchUtilsNextCursorTests.swift +++ b/packages/expo-observe/ios/Tests/DispatchUtilsNextCursorTests.swift @@ -42,6 +42,16 @@ struct DispatchUtilsNextCursorTests { #expect(next == 10) } + @Test + func `payloadTooLarge retains current cursor`() { + let next = DispatchUtils.nextCursor( + for: .payloadTooLarge, + currentCursor: 10, + highestId: 20 + ) + #expect(next == 10) + } + /// `.partialSuccess` advances the cursor like `.success` does: the bytes landed on the /// server (a subset was rejected server-side, but the batch as a whole was accepted), so /// re-sending the same rows would just trip the same rejection. diff --git a/packages/expo-observe/ios/Tests/DispatchUtilsRetryGateTests.swift b/packages/expo-observe/ios/Tests/DispatchUtilsRetryGateTests.swift index f3c7bacd2f13bd..4036a56dfd6997 100644 --- a/packages/expo-observe/ios/Tests/DispatchUtilsRetryGateTests.swift +++ b/packages/expo-observe/ios/Tests/DispatchUtilsRetryGateTests.swift @@ -70,6 +70,22 @@ struct DispatchUtilsRetryGateTests { #expect(next.dispatchAfterDate == state.dispatchAfterDate) } + @Test + func `payloadTooLarge resets the counter and leaves the gate alone`() { + let state = DispatchUtils.RetryGateState( + dispatchAfterDate: now.addingTimeInterval(60), + consecutiveRetryableFailures: 2 + ) + let next = DispatchUtils.nextRetryGateState( + result: .payloadTooLarge, + currentState: state, + now: now, + backoff: stubbedBackoff + ) + #expect(next.consecutiveRetryableFailures == 0) + #expect(next.dispatchAfterDate == state.dispatchAfterDate) + } + /// First retryable failure (from .initial): counter goes to 1, gate is now + backoff(1). /// `Retry-After` is `nil`, so we fall through to `computeBackoffDelay` (the stubbed value /// of 10 s here). diff --git a/packages/expo-observe/ios/Tests/ObservabilityClassifyResponseTests.swift b/packages/expo-observe/ios/Tests/ObservabilityClassifyResponseTests.swift index 0c90211de5ebd3..165de2c738e0bb 100644 --- a/packages/expo-observe/ios/Tests/ObservabilityClassifyResponseTests.swift +++ b/packages/expo-observe/ios/Tests/ObservabilityClassifyResponseTests.swift @@ -129,6 +129,16 @@ struct ObservabilityClassifyResponseTests { // MARK: - Non-retryable 4xx / other 5xx + @Test + func `413 returns payloadTooLarge`() { + let result = DispatchUtils.classifyResponse( + statusCode: 413, + retryAfterHeader: nil, + partialSuccess: nil + ) + #expect(result == .payloadTooLarge) + } + @Test func `400 returns nonRetryable`() { let result = DispatchUtils.classifyResponse( diff --git a/packages/expo-router/AGENTS.md b/packages/expo-router/AGENTS.md index 191452ea0b3055..a279fec0381a58 100644 --- a/packages/expo-router/AGENTS.md +++ b/packages/expo-router/AGENTS.md @@ -231,6 +231,13 @@ const screenProps = MockedComponent.mock.calls[1][0]; ## Key Concepts +### Expo Router Semantics + +- Evaluate all features exclusively from the Expo Router perspective. If a behavior is unavailable through Expo Router, React Navigation support for that behavior is irrelevant. +- `expo-router/react-navigation` is only a compatibility layer. Do not treat its capabilities as Expo Router features unless Expo Router exposes them. +- Protected routes are implemented as redirects and do not depend on `routeNames`. +- `routeNames` are stable in Expo Router except during HMR. + ### File-Based Routing Conventions - `page/index.tsx` → `/page` diff --git a/packages/expo-router/src/__tests__/dismissTo.test.ios.tsx b/packages/expo-router/src/__tests__/dismissTo.test.ios.tsx index a54e1b7ca73667..56ae3554edbd4d 100644 --- a/packages/expo-router/src/__tests__/dismissTo.test.ios.tsx +++ b/packages/expo-router/src/__tests__/dismissTo.test.ios.tsx @@ -71,6 +71,7 @@ it('should go back to a previous route in the same stack', () => { }, ], stale: false, + type: 'stack', }, }, ], @@ -126,6 +127,7 @@ it('should go back to a previous route in the same stack', () => { }, ], stale: false, + type: 'stack', }, }, ], @@ -241,10 +243,12 @@ it('should go back to a previous route in different stacks', () => { }, ], stale: false, + type: 'stack', }, }, ], stale: false, + type: 'stack', }, }, ], @@ -300,6 +304,7 @@ it('should go back to a previous route in different stacks', () => { }, ], stale: false, + type: 'stack', }, }, ], @@ -409,10 +414,12 @@ it('will replace the route if the provided href is not in the history', () => { }, ], stale: false, + type: 'stack', }, }, ], stale: false, + type: 'stack', }, }, ], diff --git a/packages/expo-router/src/__tests__/push.test.ios.tsx b/packages/expo-router/src/__tests__/push.test.ios.tsx index f2f73d4dc32035..d2751ecb26132a 100644 --- a/packages/expo-router/src/__tests__/push.test.ios.tsx +++ b/packages/expo-router/src/__tests__/push.test.ios.tsx @@ -187,10 +187,12 @@ it('stacks should always push a new route', () => { }, ], stale: false, + type: 'stack', }, }, ], stale: false, + type: 'stack', }, }, ], @@ -377,10 +379,12 @@ it('works in a nested layout Stack->Tab->Stack', () => { }, ], stale: false, + type: 'stack', }, }, ], stale: false, + type: 'tab', }, }, { @@ -508,10 +512,12 @@ it('targets the correct Stack when pushing to a nested layout', () => { }, ], stale: false, + type: 'stack', }, }, ], stale: false, + type: 'stack', }, }, { diff --git a/packages/expo-router/src/__tests__/stacks.test.ios.tsx b/packages/expo-router/src/__tests__/stacks.test.ios.tsx index b6f398f2644079..deb21d9e4d14e6 100644 --- a/packages/expo-router/src/__tests__/stacks.test.ios.tsx +++ b/packages/expo-router/src/__tests__/stacks.test.ios.tsx @@ -260,10 +260,12 @@ test('dismissAll nested', () => { }, ], stale: false, + type: 'stack', }, }, ], stale: false, + type: 'stack', }, }, ], @@ -361,10 +363,12 @@ test('dismissAll nested', () => { }, ], stale: false, + type: 'stack', }, }, ], stale: false, + type: 'stack', }, }, ], @@ -432,6 +436,7 @@ test('dismissAll nested', () => { }, ], stale: false, + type: 'stack', }, }, ], diff --git a/packages/expo-router/src/layouts/stack-router.ts b/packages/expo-router/src/layouts/stack-router.ts index 71700387071c53..299bdd53815c04 100644 --- a/packages/expo-router/src/layouts/stack-router.ts +++ b/packages/expo-router/src/layouts/stack-router.ts @@ -20,6 +20,7 @@ import { StackRouter as RNStackRouter, } from '../react-navigation/native'; import type { NativeStackNavigatorProps } from '../react-navigation/native-stack'; +import { ensureStateType } from '../react-navigation/routers/ensureStateType'; import type { SingularOptions } from '../useScreens'; import { getSingularId } from '../useScreens'; @@ -81,6 +82,8 @@ export const stackRouterOverride: NonNullable { return { getStateForAction: (state, action, options) => { + state = ensureStateType(state, 'stack'); + if (action.target && action.target !== state.key) { return null; } @@ -344,7 +347,6 @@ export const stackRouterOverride: NonNullable { if (r.key !== route?.key) { return r; @@ -376,7 +378,6 @@ export const stackRouterOverride: NonNullable { { key: 'bar-5', name: 'bar', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('baz')); @@ -695,6 +696,7 @@ test("prevents removing a screen with 'removePrevented' event", () => { }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -712,6 +714,7 @@ test("prevents removing a screen with 'removePrevented' event", () => { { key: 'baz-7', name: 'baz' }, ], stale: false, + type: 'stack', }); act(() => { @@ -725,6 +728,7 @@ test("prevents removing a screen with 'removePrevented' event", () => { routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -795,6 +799,7 @@ test("prevents removing a child screen with 'removePrevented' event", () => { { key: 'bar-5', name: 'bar', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('baz')); @@ -822,6 +827,7 @@ test("prevents removing a child screen with 'removePrevented' event", () => { }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -849,6 +855,7 @@ test("prevents removing a child screen with 'removePrevented' event", () => { }, ], stale: false, + type: 'stack', }); act(() => { @@ -862,6 +869,7 @@ test("prevents removing a child screen with 'removePrevented' event", () => { routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -937,6 +945,7 @@ test("prevents removing a grand child screen with 'removePrevented' event", () = { key: 'bar-5', name: 'bar', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('baz')); @@ -976,6 +985,7 @@ test("prevents removing a grand child screen with 'removePrevented' event", () = }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -1015,6 +1025,7 @@ test("prevents removing a grand child screen with 'removePrevented' event", () = }, ], stale: false, + type: 'stack', }); act(() => { @@ -1028,6 +1039,7 @@ test("prevents removing a grand child screen with 'removePrevented' event", () = routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -1144,6 +1156,7 @@ test("prevents removing by multiple screens with 'removePrevented' event", () => routeNames: ['foo', 'bar', 'baz', 'bax'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -1217,6 +1230,7 @@ test("prevents removing a child screen with 'removePrevented' event with 'resetR }, ], stale: false, + type: 'stack', }); act(() => @@ -1251,6 +1265,7 @@ test("prevents removing a child screen with 'removePrevented' event with 'resetR }, ], stale: false, + type: 'stack', }); act(() => { @@ -1274,5 +1289,6 @@ test("prevents removing a child screen with 'removePrevented' event with 'resetR routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx index f45b70642ac14e..9390f787a47064 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx @@ -275,6 +275,7 @@ test("prevents removing a screen with 'usePreventRemove' hook", () => { { key: 'bar-5', name: 'bar', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('baz')); @@ -290,6 +291,7 @@ test("prevents removing a screen with 'usePreventRemove' hook", () => { { key: 'baz-7', name: 'baz', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -307,6 +309,7 @@ test("prevents removing a screen with 'usePreventRemove' hook", () => { { key: 'baz-7', name: 'baz' }, ], stale: false, + type: 'stack', }); act(() => setPreventRemove(false)); @@ -320,6 +323,7 @@ test("prevents removing a screen with 'usePreventRemove' hook", () => { routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -425,6 +429,7 @@ test("prevents removing a screen when 'usePreventRemove' hook is called multiple { key: 'bar-5', name: 'bar', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('baz')); @@ -440,6 +445,7 @@ test("prevents removing a screen when 'usePreventRemove' hook is called multiple { key: 'baz-9', name: 'baz', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -457,6 +463,7 @@ test("prevents removing a screen when 'usePreventRemove' hook is called multiple { key: 'baz-9', name: 'baz' }, ], stale: false, + type: 'stack', }); act(() => setPreventRemove(false)); @@ -470,6 +477,7 @@ test("prevents removing a screen when 'usePreventRemove' hook is called multiple routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -522,6 +530,7 @@ test("should have no effect when 'usePreventRemove' hook is set to false", () => { key: 'bar-5', name: 'bar', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('baz')); @@ -537,6 +546,7 @@ test("should have no effect when 'usePreventRemove' hook is set to false", () => { key: 'baz-7', name: 'baz', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -549,6 +559,7 @@ test("should have no effect when 'usePreventRemove' hook is set to false", () => routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('bar')); @@ -561,6 +572,7 @@ test("should have no effect when 'usePreventRemove' hook is set to false", () => routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); expect(onPreventRemove).toHaveBeenCalledTimes(0); @@ -625,6 +637,7 @@ test("prevents removing a child screen with 'usePreventRemove' hook", () => { { key: 'bar-5', name: 'bar', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('baz')); @@ -652,6 +665,7 @@ test("prevents removing a child screen with 'usePreventRemove' hook", () => { }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -679,6 +693,7 @@ test("prevents removing a child screen with 'usePreventRemove' hook", () => { }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -704,6 +719,7 @@ test("prevents removing a child screen with 'usePreventRemove' hook", () => { }, ], stale: false, + type: 'stack', }); act(() => setPreventRemove(false)); @@ -718,6 +734,7 @@ test("prevents removing a child screen with 'usePreventRemove' hook", () => { routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -786,6 +803,7 @@ test("prevents removing a grand child screen with 'usePreventRemove' hook", () = { key: 'bar-5', name: 'bar', params: undefined, path: undefined }, ], stale: false, + type: 'stack', }); act(() => ref.current?.navigate('baz')); @@ -825,6 +843,7 @@ test("prevents removing a grand child screen with 'usePreventRemove' hook", () = }, ], stale: false, + type: 'stack', }); act(() => ref.current?.dispatch(StackActions.popTo('foo'))); @@ -864,6 +883,7 @@ test("prevents removing a grand child screen with 'usePreventRemove' hook", () = }, ], stale: false, + type: 'stack', }); act(() => setPreventRemove(false)); @@ -878,6 +898,7 @@ test("prevents removing a grand child screen with 'usePreventRemove' hook", () = routeNames: ['foo', 'bar', 'baz'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -981,6 +1002,7 @@ test("prevents removing by multiple screens with 'usePreventRemove' hook", () => }, ], stale: false, + type: 'stack', }; expect(onStateChange).toHaveBeenCalledTimes(1); @@ -1028,6 +1050,7 @@ test("prevents removing by multiple screens with 'usePreventRemove' hook", () => routeNames: ['foo', 'bar', 'baz', 'bax'], routes: [{ key: 'foo-2', name: 'foo' }], stale: false, + type: 'stack', }); }); @@ -1101,6 +1124,7 @@ test("prevents removing a child screen with 'usePreventRemove' hook with 'resetR }, ], stale: false, + type: 'stack', }); act(() => @@ -1134,5 +1158,6 @@ test("prevents removing a child screen with 'usePreventRemove' hook with 'resetR }, ], stale: false, + type: 'stack', }); }); diff --git a/packages/expo-router/src/react-navigation/routers/DrawerRouter.tsx b/packages/expo-router/src/react-navigation/routers/DrawerRouter.tsx index 6e7229be9d4c4e..1907991d9065d2 100644 --- a/packages/expo-router/src/react-navigation/routers/DrawerRouter.tsx +++ b/packages/expo-router/src/react-navigation/routers/DrawerRouter.tsx @@ -9,6 +9,7 @@ import { TabRouter, type TabRouterOptions, } from './TabRouter'; +import { ensureStateType } from './ensureStateType'; import type { CommonNavigationAction, ParamListBase, PartialState, Router } from './types'; export type DrawerStatus = 'open' | 'closed'; @@ -87,9 +88,9 @@ export function DrawerRouter({ // `ensureStateHistory` is typed for the tab state. The drawer state differs only by the extra // drawer entries in `history`, which reconstruction never produces. - const ensureDrawerStateHistory = (state: DrawerNavigationState) => + const ensureDrawerStateOptionalProperties = (state: DrawerNavigationState) => ensureStateHistory( - state as unknown as TabNavigationState, + ensureStateType(state, 'drawer') as unknown as TabNavigationState, backBehavior, initialRouteName ) as unknown as DrawerNavigationState; @@ -179,14 +180,14 @@ export function DrawerRouter({ }, getStateForRouteFocus(state, key) { - const result = router.getStateForRouteFocus(state, key); + const result = router.getStateForRouteFocus(ensureDrawerStateOptionalProperties(state), key); return closeDrawer(result); }, getStateForAction(inputState, action, options) { // Restore route history before drawer actions can add drawer-only history. - const state = ensureDrawerStateHistory(inputState); + const state = ensureDrawerStateOptionalProperties(inputState); switch (action.type) { case 'OPEN_DRAWER': diff --git a/packages/expo-router/src/react-navigation/routers/StackRouter.tsx b/packages/expo-router/src/react-navigation/routers/StackRouter.tsx index 9d0a37834f4ed9..0ee916bbe64dbd 100644 --- a/packages/expo-router/src/react-navigation/routers/StackRouter.tsx +++ b/packages/expo-router/src/react-navigation/routers/StackRouter.tsx @@ -3,6 +3,7 @@ import { nanoid } from 'nanoid/non-secure'; import { isArrayEqual } from '../core/isArrayEqual'; import { BaseRouter } from './BaseRouter'; import { createRouteFromAction } from './createRouteFromAction'; +import { ensureStateType } from './ensureStateType'; import type { CommonNavigationAction, DefaultRouterOptions, @@ -190,6 +191,7 @@ export function StackRouter(options: StackRouterOptions) { > = { ...BaseRouter, + // TODO: Keep this value in sync with the `ensureStateType` calls below. type: 'stack', getRehydratedState(partialState, { routeNames }) { @@ -231,14 +233,16 @@ export function StackRouter(options: StackRouterOptions) { }); } - return { - stale: false, - type: 'stack', - key: `stack-${nanoid()}`, - index: routes.length - 1, - routeNames, - routes: routes.concat(preloadedRoutes), - }; + return ensureStateType( + { + stale: false, + key: `stack-${nanoid()}`, + index: routes.length - 1, + routeNames, + routes: routes.concat(preloadedRoutes), + }, + 'stack' + ); }, getStateForDeclaredRoutes(state, routeNames) { @@ -258,7 +262,8 @@ export function StackRouter(options: StackRouterOptions) { return { ...filteredState, index: Math.max(0, survivingActiveCount - 1) }; }, - getStateForRouteFocus(state, key) { + getStateForRouteFocus(inputState, key) { + const state = ensureStateType(inputState, 'stack'); const { activeRoutes } = getStackRoutes(state); const index = activeRoutes.findIndex((r) => r.key === key); @@ -273,7 +278,8 @@ export function StackRouter(options: StackRouterOptions) { }; }, - getStateForAction(state, action, options) { + getStateForAction(inputState, action, options) { + const state = ensureStateType(inputState, 'stack'); const { activeRoutes, preloadedRoutes } = getStackRoutes(state); switch (action.type) { @@ -614,7 +620,6 @@ export function StackRouter(options: StackRouterOptions) { if (route) { return { ...state, - type: 'stack', routes: state.routes.map((r) => { if (r.key !== route?.key) { return r; @@ -636,13 +641,19 @@ export function StackRouter(options: StackRouterOptions) { ) .concat(createRouteFromAction({ action })) ), - type: 'stack', }; } } - default: - return BaseRouter.getStateForAction(state, action); + default: { + const result = BaseRouter.getStateForAction(state, action); + + if (result === null || result.stale !== false) { + return result; + } + + return ensureStateType(result, 'stack'); + } } }, diff --git a/packages/expo-router/src/react-navigation/routers/TabRouter.tsx b/packages/expo-router/src/react-navigation/routers/TabRouter.tsx index b20daa0857e422..50c2d004c8fee0 100644 --- a/packages/expo-router/src/react-navigation/routers/TabRouter.tsx +++ b/packages/expo-router/src/react-navigation/routers/TabRouter.tsx @@ -4,6 +4,7 @@ import { orderRoutesByRouteNames } from '../../utils/orderRoutesByRouteNames'; import { isArrayEqual } from '../core/isArrayEqual'; import { BaseRouter } from './BaseRouter'; import { createRouteFromAction } from './createRouteFromAction'; +import { ensureStateType } from './ensureStateType'; import type { CommonNavigationAction, DefaultRouterOptions, @@ -329,15 +330,17 @@ export function TabRouter({ const history = state.history?.filter((it) => routeKeys.includes(it.key)) ?? []; return changeIndex( - { - stale: false, - type: 'tab', - key: `tab-${nanoid()}`, - index, - routeNames, - history, - routes, - }, + ensureStateType( + { + stale: false, + key: `tab-${nanoid()}`, + index, + routeNames, + history, + routes, + }, + 'tab' + ), index, backBehavior, initialRouteName @@ -345,7 +348,10 @@ export function TabRouter({ }, getStateForRouteFocus(inputState, key) { - const state = ensureStateHistory(inputState, backBehavior, initialRouteName); + const state = ensureStateType( + ensureStateHistory(inputState, backBehavior, initialRouteName), + 'tab' + ); const index = state.routes.findIndex((r) => r.key === key); if (index === -1 || index === state.index) { @@ -356,7 +362,10 @@ export function TabRouter({ }, getStateForAction(inputState, action, { routeGetIdList }) { - const state = ensureStateHistory(inputState, backBehavior, initialRouteName); + const state = ensureStateType( + ensureStateHistory(inputState, backBehavior, initialRouteName), + 'tab' + ); if (action.target && action.target !== state.key) { return null; @@ -682,8 +691,18 @@ export function TabRouter({ }; } - default: - return BaseRouter.getStateForAction(state, action); + default: { + const result = BaseRouter.getStateForAction(state, action); + + if (result === null || result.stale !== false) { + return result; + } + + return ensureStateType( + ensureStateHistory(result, backBehavior, initialRouteName), + state.type + ); + } } }, diff --git a/packages/expo-router/src/react-navigation/routers/__tests__/DrawerRouter.test.tsx b/packages/expo-router/src/react-navigation/routers/__tests__/DrawerRouter.test.tsx index 6a881b575aa0f7..873d9e22dd667b 100644 --- a/packages/expo-router/src/react-navigation/routers/__tests__/DrawerRouter.test.tsx +++ b/packages/expo-router/src/react-navigation/routers/__tests__/DrawerRouter.test.tsx @@ -13,6 +13,71 @@ import { createInitialState } from '../../core/createInitialState'; jest.mock('nanoid/non-secure', () => ({ nanoid: () => 'test' })); +test('actions return drawer metadata for state without router metadata', () => { + const router = DrawerRouter({}); + const options: RouterConfigOptions = { + routeNames: ['bar', 'baz'], + routeGetIdList: {}, + }; + const createState = (): DrawerNavigationState => ({ + stale: false, + key: 'root', + index: 1, + routeNames: options.routeNames, + routes: [ + { key: 'bar', name: 'bar' }, + { key: 'baz', name: 'baz' }, + ], + }); + const resetState = createState(); + const actions = [ + DrawerActions.jumpTo('bar'), + DrawerActions.openDrawer(), + CommonActions.goBack(), + CommonActions.reset({ ...resetState, index: 0, routes: [resetState.routes[0]!] }), + ]; + + for (const action of actions) { + const result = router.getStateForAction(createState(), action, options); + expect(result).toMatchObject({ type: 'drawer', history: expect.any(Array) }); + } +}); + +test('route focus returns drawer metadata for state without router metadata', () => { + const state: DrawerNavigationState = { + stale: false, + key: 'root', + index: 0, + routeNames: ['bar', 'baz'], + routes: [ + { key: 'bar', name: 'bar' }, + { key: 'baz', name: 'baz' }, + ], + }; + + expect(DrawerRouter({}).getStateForRouteFocus(state, 'baz')).toMatchObject({ + type: 'drawer', + history: expect.any(Array), + }); +}); + +test('passes partial RESET state through unchanged', () => { + const partialState = { routes: [{ name: 'bar' }] }; + const state: DrawerNavigationState = { + stale: false, + key: 'root', + index: 0, + routeNames: ['bar'], + routes: [{ key: 'bar', name: 'bar' }], + }; + const result = DrawerRouter({}).getStateForAction(state, CommonActions.reset(partialState), { + routeNames: ['bar'], + routeGetIdList: {}, + }); + + expect(result).toBe(partialState); +}); + type DrawerHistory = NonNullable['history']>; const stateWithoutHistory = (): DrawerNavigationState => ({ diff --git a/packages/expo-router/src/react-navigation/routers/__tests__/StackRouter.test.tsx b/packages/expo-router/src/react-navigation/routers/__tests__/StackRouter.test.tsx index 6444204e382734..fda4102fd6fd70 100644 --- a/packages/expo-router/src/react-navigation/routers/__tests__/StackRouter.test.tsx +++ b/packages/expo-router/src/react-navigation/routers/__tests__/StackRouter.test.tsx @@ -1,4 +1,4 @@ -import { expect, jest, test } from '@jest/globals'; +import { describe, expect, jest, test } from '@jest/globals'; import { CommonActions, @@ -12,6 +12,71 @@ import { createInitialState } from '../../core/createInitialState'; jest.mock('nanoid/non-secure', () => ({ nanoid: () => 'test' })); +describe('state without router type', () => { + const options: RouterConfigOptions = { + routeNames: ['bar', 'baz'], + routeGetIdList: { bar: ({ params }) => params?.id }, + }; + const createState = (index = 1): StackNavigationState => ({ + stale: false, + key: 'root', + index, + routeNames: options.routeNames, + routes: [ + { key: 'bar', name: 'bar', params: { id: 'one' } }, + { key: 'baz', name: 'baz' }, + ], + }); + + test.each([ + StackActions.push('bar'), + CommonActions.navigate('bar'), + CommonActions.goBack(), + CommonActions.preload('bar', { id: 'one' }), + CommonActions.preload('baz', { id: 'new' }), + { type: 'ROUTE_NAMES_CHANGED', payload: { routeNames: options.routeNames } } as const, + ])('$type returns stack state', (action) => { + expect(StackRouter({}).getStateForAction(createState(), action, options)?.type).toBe('stack'); + }); + + test('stamps stack state on route focus', () => { + expect(StackRouter({}).getStateForRouteFocus(createState(0), 'baz').type).toBe('stack'); + }); + + test('stamps complete RESET state', () => { + const state = createState(); + const result = StackRouter({}).getStateForAction( + state, + CommonActions.reset({ ...state, index: 0, routes: [state.routes[0]!] }), + options + ); + + expect(result?.type).toBe('stack'); + }); + + test('preserves the type from complete RESET state', () => { + const state = createState(); + const result = StackRouter({}).getStateForAction( + state, + CommonActions.reset({ ...state, type: 'stack' }), + options + ); + + expect(result?.type).toBe('stack'); + }); + + test('passes partial RESET state through unchanged', () => { + const partialState = { routes: [{ name: 'bar' }] }; + const result = StackRouter({}).getStateForAction( + createState(), + CommonActions.reset(partialState), + options + ); + + expect(result).toBe(partialState); + }); +}); + test('gets rehydrated state from partial state', () => { const router = StackRouter({}); @@ -265,11 +330,14 @@ test('gets state on route names change with initialRouteName', () => { }); }); -test('returns the same stack state when route names already match', () => { +test('returns the same complete stack state when route names already match', () => { const router = StackRouter({}); - const state = createInitialState>({ - routeNames: ['bar', 'baz'], - }); + const state = { + ...createInitialState>({ + routeNames: ['bar', 'baz'], + }), + type: 'stack' as const, + }; expect( router.getStateForAction( @@ -333,6 +401,7 @@ test('handles navigate action', () => { ) ).toEqual({ stale: false, + type: 'stack', key: 'root', index: 2, routeNames: ['baz', 'bar', 'qux'], diff --git a/packages/expo-router/src/react-navigation/routers/__tests__/TabRouter.test.tsx b/packages/expo-router/src/react-navigation/routers/__tests__/TabRouter.test.tsx index 03da111eda2070..dd790b0035dc80 100644 --- a/packages/expo-router/src/react-navigation/routers/__tests__/TabRouter.test.tsx +++ b/packages/expo-router/src/react-navigation/routers/__tests__/TabRouter.test.tsx @@ -14,6 +14,77 @@ import { jest.mock('nanoid/non-secure', () => ({ nanoid: jest.fn(() => 'test') })); +describe('state without router metadata', () => { + const options: RouterConfigOptions = { + routeNames: ['bar', 'baz'], + routeGetIdList: {}, + }; + const createState = (index = 1): TabNavigationState => ({ + stale: false, + key: 'root', + index, + routeNames: options.routeNames, + routes: [ + { key: 'bar', name: 'bar' }, + { key: 'baz', name: 'baz' }, + ], + }); + + test('handled actions return tab type and history', () => { + const result = TabRouter({}).getStateForAction( + createState(0), + TabActions.jumpTo('baz'), + options + ); + + expect(result).toMatchObject({ type: 'tab', history: expect.any(Array) }); + }); + + test('complete RESET state gets tab type and rebuilt history', () => { + const state = createState(); + const result = TabRouter({}).getStateForAction( + state, + CommonActions.reset({ ...state, index: 0, routes: [state.routes[0]!] }), + options + ); + + expect(result).toMatchObject({ + type: 'tab', + history: [{ type: 'route', key: 'bar' }], + }); + }); + + test('passes partial RESET state through unchanged', () => { + const partialState = { routes: [{ name: 'bar' }] }; + const result = TabRouter({}).getStateForAction( + createState(), + CommonActions.reset(partialState), + options + ); + + expect(result).toBe(partialState); + }); + + test('route focus returns tab type and history', () => { + expect(TabRouter({}).getStateForRouteFocus(createState(0), 'baz')).toMatchObject({ + type: 'tab', + history: expect.any(Array), + }); + }); + + test('preserves drawer type when used by DrawerRouter', () => { + const state = { ...createState(0), type: 'drawer' as const }; + // DrawerRouter delegates to TabRouter with the structurally compatible drawer state. + const result = TabRouter({}).getStateForAction( + state as unknown as TabNavigationState, + TabActions.jumpTo('baz'), + options + ); + + expect(result?.type).toBe('drawer'); + }); +}); + const createTabState = ( options: RouterConfigOptions, initialRouteName?: string diff --git a/packages/expo-router/src/react-navigation/routers/ensureStateType.tsx b/packages/expo-router/src/react-navigation/routers/ensureStateType.tsx new file mode 100644 index 00000000000000..4990157419ebc1 --- /dev/null +++ b/packages/expo-router/src/react-navigation/routers/ensureStateType.tsx @@ -0,0 +1,20 @@ +type ExistingStateType = State extends { type?: infer Type extends string } + ? NonNullable + : never; + +type StateWithType = State & { + type: ExistingStateType | Type; +}; + +// TODO(@ubax): align this type with router.type +export function ensureStateType( + state: State & { type?: string }, + type: Type +): StateWithType { + if (state.type != null) { + // The null check guarantees the optional property required by the return type. + return state as StateWithType; + } + + return { ...state, type }; +} diff --git a/packages/expo-secure-store/CHANGELOG.md b/packages/expo-secure-store/CHANGELOG.md index eba529b4b98f87..2650a35d35550d 100644 --- a/packages/expo-secure-store/CHANGELOG.md +++ b/packages/expo-secure-store/CHANGELOG.md @@ -10,6 +10,9 @@ ### 🐛 Bug fixes +- [iOS] Reject `deleteItemAsync` when the keychain refuses the delete, instead of resolving as if the item was removed. +- [iOS] Apply `keychainAccessible` when overwriting an existing item, instead of silently keeping the accessibility it was first stored with. ([#49128](https://github.com/expo/expo/pull/49128) by [@JoRo-Code](https://github.com/JoRo-Code) and [@behenate](https://github.com/behenate)) + ### 💡 Others ## 57.0.1 - 2026-07-15 diff --git a/packages/expo-secure-store/ios/SecureStoreModule.swift b/packages/expo-secure-store/ios/SecureStoreModule.swift index 584df218365e2e..9bc191e02aa48a 100644 --- a/packages/expo-secure-store/ios/SecureStoreModule.swift +++ b/packages/expo-secure-store/ios/SecureStoreModule.swift @@ -45,9 +45,17 @@ public final class SecureStoreModule: Module { let authSearchDictionary = query(with: key, options: options, requireAuthentication: true) let legacySearchDictionary = query(with: key, options: options) - SecItemDelete(legacySearchDictionary as CFDictionary) - SecItemDelete(authSearchDictionary as CFDictionary) - SecItemDelete(noAuthSearchDictionary as CFDictionary) + // Delete all three aliases before reporting a failure, so that a failing alias + // cannot leave the remaining entries behind. + let statuses = [ + SecItemDelete(legacySearchDictionary as CFDictionary), + SecItemDelete(authSearchDictionary as CFDictionary), + SecItemDelete(noAuthSearchDictionary as CFDictionary) + ] + + if let failure = statuses.first(where: { $0 != errSecSuccess && $0 != errSecItemNotFound }) { + throw KeyChainException(failure) + } } Function("canUseBiometricAuthentication") {() -> Bool in @@ -101,12 +109,7 @@ public final class SecureStoreModule: Module { throw MissingPlistKeyException() } - var error: Unmanaged? = nil - guard let accessOptions = SecAccessControlCreateWithFlags(kCFAllocatorDefault, accessibility, .biometryCurrentSet, &error) else { - let errorCode = error.map { CFErrorGetCode($0.takeRetainedValue()) } - throw SecAccessControlError(errorCode) - } - setItemQuery[kSecAttrAccessControl as String] = accessOptions + setItemQuery[kSecAttrAccessControl as String] = try accessControlWith(options: options) } let status = SecItemAdd(setItemQuery as CFDictionary, nil) @@ -127,8 +130,19 @@ public final class SecureStoreModule: Module { private func update(value: String, with key: String, options: SecureStoreOptions) throws -> Bool { var query = query(with: key, options: options, requireAuthentication: options.requireAuthentication) - let valueData = value.data(using: .utf8) - let updateDictionary = [kSecValueData as String: valueData] + let valueData = Data(value.utf8) + + var updateDictionary: [CFString: Any] = [kSecValueData: valueData] + + // Keychain updates keep the existing access settings by default, so include the + // requested accessibility setting when one is provided. + if options.keychainAccessible != nil { + if options.requireAuthentication { + updateDictionary[kSecAttrAccessControl] = try accessControlWith(options: options) + } else { + updateDictionary[kSecAttrAccessible] = attributeWith(options: options) + } + } if let authPrompt = options.authenticationPrompt { query[kSecUseOperationPrompt as String] = authPrompt @@ -192,7 +206,7 @@ public final class SecureStoreModule: Module { } private func attributeWith(options: SecureStoreOptions) -> CFString { - switch options.keychainAccessible { + switch options.keychainAccessible ?? .whenUnlocked { case .afterFirstUnlock: return kSecAttrAccessibleAfterFirstUnlock case .afterFirstUnlockThisDeviceOnly: @@ -210,6 +224,20 @@ public final class SecureStoreModule: Module { } } + private func accessControlWith(options: SecureStoreOptions) throws -> SecAccessControl { + var error: Unmanaged? + guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + attributeWith(options: options), + .biometryCurrentSet, + &error + ) else { + let errorCode = error.map { CFErrorGetCode($0.takeRetainedValue()) } + throw SecAccessControlError(errorCode) + } + return accessControl + } + private func validate(for key: String) -> String? { let trimmedKey = key.trimmingCharacters(in: .whitespaces) if trimmedKey.isEmpty { diff --git a/packages/expo-secure-store/ios/SecureStoreOptions.swift b/packages/expo-secure-store/ios/SecureStoreOptions.swift index 7e3fa4dfca55cd..ecaf891f696a85 100644 --- a/packages/expo-secure-store/ios/SecureStoreOptions.swift +++ b/packages/expo-secure-store/ios/SecureStoreOptions.swift @@ -5,7 +5,7 @@ internal struct SecureStoreOptions: Record { var authenticationPrompt: String? @Field - var keychainAccessible: SecureStoreAccessible = .whenUnlocked + var keychainAccessible: SecureStoreAccessible? @Field var keychainService: String? diff --git a/packages/expo-secure-store/src/SecureStore.ts b/packages/expo-secure-store/src/SecureStore.ts index 1f00d4c33c21fe..68720fcd71d706 100644 --- a/packages/expo-secure-store/src/SecureStore.ts +++ b/packages/expo-secure-store/src/SecureStore.ts @@ -99,6 +99,8 @@ export type SecureStoreOptions = { authenticationPrompt?: string; /** * Specifies when the stored entry is accessible, using iOS's `kSecAttrAccessible` property. + * When an existing entry is overwritten, passing this option updates the entry's accessibility, + * and omitting it keeps the accessibility the entry was stored with. * @see Apple's documentation on [keychain item accessibility](https://developer.apple.com/documentation/security/ksecattraccessible/). * @default SecureStore.WHEN_UNLOCKED * @platform ios diff --git a/packages/expo-sqlite/CHANGELOG.md b/packages/expo-sqlite/CHANGELOG.md index b68f14065dc6bf..f9216b748ed562 100644 --- a/packages/expo-sqlite/CHANGELOG.md +++ b/packages/expo-sqlite/CHANGELOG.md @@ -10,6 +10,7 @@ ### 🐛 Bug fixes +- [Android][iOS] Fix `deleteDatabaseAsync` and `deleteDatabaseSync` leaving `-journal`, `-wal` and `-shm` sidecar files behind. ([#49125](https://github.com/expo/expo/pull/49125) by [@sbaiahmed1](https://github.com/sbaiahmed1)) - [tvOS] Fix path for DB creation. ([#46715](https://github.com/expo/expo/pull/46715) by [@douglowder](https://github.com/douglowder)) - Fixed the devtools plugin bundle missing its `wa-sqlite.wasm` asset. ([#48542](https://github.com/expo/expo/pull/48542) by [@kudo](https://github.com/kudo)) diff --git a/packages/expo-sqlite/android/build.gradle b/packages/expo-sqlite/android/build.gradle index 01578dc3052a7d..30dfc355f6ea99 100644 --- a/packages/expo-sqlite/android/build.gradle +++ b/packages/expo-sqlite/android/build.gradle @@ -95,4 +95,6 @@ dependencies { compileOnly 'io.github.ronickg:openssl:3.3.2-1' } compileOnly 'com.facebook.fbjni:fbjni:0.3.0' + + testImplementation 'junit:junit:4.13.2' } diff --git a/packages/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteHelpers.kt b/packages/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteHelpers.kt index 4b8e9de60421cf..106411dbf0e3bf 100644 --- a/packages/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteHelpers.kt +++ b/packages/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteHelpers.kt @@ -3,6 +3,26 @@ package expo.modules.sqlite import java.io.File import java.io.IOException +/** + * Deletes the database file together with its `-journal`, `-wal` and `-shm` sidecar files, + * mirroring the behavior of Android's `SQLiteDatabase.deleteDatabase()`. + */ +@Throws(DatabaseNotFoundException::class, DeleteDatabaseFileException::class) +internal fun deleteDatabaseFiles(dbFile: File, databaseName: String) { + if (!dbFile.exists()) { + throw DatabaseNotFoundException(databaseName) + } + if (!dbFile.delete()) { + throw DeleteDatabaseFileException(databaseName) + } + for (suffix in listOf("-journal", "-wal", "-shm")) { + val sidecarFile = File(dbFile.path + suffix) + if (sidecarFile.exists()) { + sidecarFile.delete() + } + } +} + @Throws(IOException::class) internal fun ensureDirExists(dir: File): File { if (!dir.isDirectory) { diff --git a/packages/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteModule.kt b/packages/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteModule.kt index 442c3e5161b3ee..a29f66ea2665b7 100644 --- a/packages/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteModule.kt +++ b/packages/expo-sqlite/android/src/main/java/expo/modules/sqlite/SQLiteModule.kt @@ -512,13 +512,7 @@ class SQLiteModule : Module() { if (databasePath == MEMORY_DB_NAME) { return } - val dbFile = File(ensureDatabasePathExists(databasePath)) - if (!dbFile.exists()) { - throw DatabaseNotFoundException(databasePath) - } - if (!dbFile.delete()) { - throw DeleteDatabaseFileException(databasePath) - } + deleteDatabaseFiles(File(ensureDatabasePathExists(databasePath)), databasePath) } @Throws(AccessClosedResourceException::class, SQLiteErrorException::class) diff --git a/packages/expo-sqlite/android/src/test/java/expo/modules/sqlite/SQLiteHelpersTest.kt b/packages/expo-sqlite/android/src/test/java/expo/modules/sqlite/SQLiteHelpersTest.kt new file mode 100644 index 00000000000000..ac8daa5991d773 --- /dev/null +++ b/packages/expo-sqlite/android/src/test/java/expo/modules/sqlite/SQLiteHelpersTest.kt @@ -0,0 +1,62 @@ +package expo.modules.sqlite + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class SQLiteHelpersTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private fun createFile(name: String): File = tempFolder.newFile(name).apply { writeText("data") } + + @Test + fun `deletes the main database file`() { + val dbFile = createFile("test.db") + + deleteDatabaseFiles(dbFile, "test.db") + + assertFalse(dbFile.exists()) + } + + @Test + fun `deletes journal, wal and shm sidecar files along with the database`() { + val dbFile = createFile("test.db") + val journalFile = createFile("test.db-journal") + val walFile = createFile("test.db-wal") + val shmFile = createFile("test.db-shm") + + deleteDatabaseFiles(dbFile, "test.db") + + assertFalse(dbFile.exists()) + assertFalse(journalFile.exists()) + assertFalse(walFile.exists()) + assertFalse(shmFile.exists()) + } + + @Test + fun `keeps unrelated files intact`() { + val dbFile = createFile("test.db") + val otherDbFile = createFile("test2.db") + val otherWalFile = createFile("test2.db-wal") + + deleteDatabaseFiles(dbFile, "test.db") + + assertFalse(dbFile.exists()) + assertTrue(otherDbFile.exists()) + assertTrue(otherWalFile.exists()) + } + + @Test + fun `throws when the main database file does not exist`() { + val dbFile = File(tempFolder.root, "missing.db") + + assertThrows(DatabaseNotFoundException::class.java) { + deleteDatabaseFiles(dbFile, "missing.db") + } + } +} diff --git a/packages/expo-sqlite/ios/DatabaseFileUtils.swift b/packages/expo-sqlite/ios/DatabaseFileUtils.swift new file mode 100644 index 00000000000000..cdf8112c9ad440 --- /dev/null +++ b/packages/expo-sqlite/ios/DatabaseFileUtils.swift @@ -0,0 +1,29 @@ +// Copyright 2015-present 650 Industries. All rights reserved. + +import Foundation + +internal enum DatabaseFileUtils { + /** + Deletes the database file at the given path together with its `-journal`, `-wal` and `-shm` + sidecar files, mirroring the behavior of Android's `SQLiteDatabase.deleteDatabase()`. + */ + static func deleteDatabaseFiles(atPath path: String) throws { + let fileManager = FileManager.default + if !fileManager.fileExists(atPath: path) { + throw DatabaseNotFoundException(path) + } + + do { + try fileManager.removeItem(atPath: path) + } catch { + throw DeleteDatabaseFileException(path) + } + + for suffix in ["-journal", "-wal", "-shm"] { + let sidecarPath = path + suffix + if fileManager.fileExists(atPath: sidecarPath) { + try? fileManager.removeItem(atPath: sidecarPath) + } + } + } +} diff --git a/packages/expo-sqlite/ios/ExpoSQLite.podspec b/packages/expo-sqlite/ios/ExpoSQLite.podspec index a04be0617ed017..a1ddeff0ad3a7f 100644 --- a/packages/expo-sqlite/ios/ExpoSQLite.podspec +++ b/packages/expo-sqlite/ios/ExpoSQLite.podspec @@ -70,6 +70,15 @@ Pod::Spec.new do |s| 'OTHER_SWIFT_FLAGS' => '$(inherited) ' + swift_flags, } s.source_files = "**/*.{c,h,m,swift}" + s.exclude_files = 'Tests' + + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = 'Tests' + test_spec.pod_target_xcconfig = { + # The test bundle links the static ExpoModulesCore dependency chain, which contains C++. + 'OTHER_LDFLAGS' => '-lc++' + } + end vendored_frameworks = [] if podfile_properties['expo.sqlite.withSQLiteVecExtension'] == 'true' diff --git a/packages/expo-sqlite/ios/SQLiteModule.swift b/packages/expo-sqlite/ios/SQLiteModule.swift index f15908d03310e6..fc8c1cd8630a9b 100644 --- a/packages/expo-sqlite/ios/SQLiteModule.swift +++ b/packages/expo-sqlite/ios/SQLiteModule.swift @@ -516,16 +516,7 @@ public final class SQLiteModule: Module { return } let path = try ensureDatabasePathExists(path: databasePath).toFilePath() - - if !FileManager.default.fileExists(atPath: path) { - throw DatabaseNotFoundException(path) - } - - do { - try FileManager.default.removeItem(atPath: path) - } catch { - throw DeleteDatabaseFileException(path) - } + try DatabaseFileUtils.deleteDatabaseFiles(atPath: path) } private func backupDatabase(destDatabase: NativeDatabase, destDatabaseName: String, sourceDatabase: NativeDatabase, sourceDatabaseName: String) throws { diff --git a/packages/expo-sqlite/ios/Tests/DatabaseFileUtilsTests.swift b/packages/expo-sqlite/ios/Tests/DatabaseFileUtilsTests.swift new file mode 100644 index 00000000000000..22b3d176a50377 --- /dev/null +++ b/packages/expo-sqlite/ios/Tests/DatabaseFileUtilsTests.swift @@ -0,0 +1,71 @@ +// Copyright 2015-present 650 Industries. All rights reserved. + +import Testing + +@testable import ExpoSQLite + +@Suite("DatabaseFileUtils") +final class DatabaseFileUtilsTests { + private let tempDir: URL + + init() throws { + tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: tempDir) + } + + private func createFile(_ name: String) -> String { + let path = tempDir.appendingPathComponent(name).path + FileManager.default.createFile(atPath: path, contents: Data("data".utf8)) + return path + } + + @Test + func `deletes the main database file`() throws { + let dbPath = createFile("test.db") + + try DatabaseFileUtils.deleteDatabaseFiles(atPath: dbPath) + + #expect(!FileManager.default.fileExists(atPath: dbPath)) + } + + @Test + func `deletes journal wal and shm sidecar files along with the database`() throws { + let dbPath = createFile("test.db") + let journalPath = createFile("test.db-journal") + let walPath = createFile("test.db-wal") + let shmPath = createFile("test.db-shm") + + try DatabaseFileUtils.deleteDatabaseFiles(atPath: dbPath) + + #expect(!FileManager.default.fileExists(atPath: dbPath)) + #expect(!FileManager.default.fileExists(atPath: journalPath)) + #expect(!FileManager.default.fileExists(atPath: walPath)) + #expect(!FileManager.default.fileExists(atPath: shmPath)) + } + + @Test + func `keeps unrelated files intact`() throws { + let dbPath = createFile("test.db") + let otherDbPath = createFile("test2.db") + let otherWalPath = createFile("test2.db-wal") + + try DatabaseFileUtils.deleteDatabaseFiles(atPath: dbPath) + + #expect(!FileManager.default.fileExists(atPath: dbPath)) + #expect(FileManager.default.fileExists(atPath: otherDbPath)) + #expect(FileManager.default.fileExists(atPath: otherWalPath)) + } + + @Test + func `throws when the main database file does not exist`() { + let dbPath = tempDir.appendingPathComponent("missing.db").path + + #expect(throws: DatabaseNotFoundException.self) { + try DatabaseFileUtils.deleteDatabaseFiles(atPath: dbPath) + } + } +}