diff --git a/apps/bare-expo/android/app/src/main/AndroidManifest.xml b/apps/bare-expo/android/app/src/main/AndroidManifest.xml
index b3eec55281562a..2751b41bb5119c 100644
--- a/apps/bare-expo/android/app/src/main/AndroidManifest.xml
+++ b/apps/bare-expo/android/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
+
@@ -12,6 +13,10 @@
+
+
+
+
diff --git a/apps/bare-expo/app.json b/apps/bare-expo/app.json
index 12705787dd38db..d88d07d063ef28 100644
--- a/apps/bare-expo/app.json
+++ b/apps/bare-expo/app.json
@@ -82,6 +82,14 @@
"sounds": ["./assets/notification.wav"]
}
],
+ [
+ "expo-media-library",
+ {
+ "photosPermission": "Allow $(PRODUCT_NAME) to access your photos",
+ "savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos",
+ "isAccessMediaLocationEnabled": true
+ }
+ ],
[
"expo-tracking-transparency",
{
diff --git a/apps/bare-expo/ios/Podfile.lock b/apps/bare-expo/ios/Podfile.lock
index dd5b21ac9ab29e..df0eedd85dcffd 100644
--- a/apps/bare-expo/ios/Podfile.lock
+++ b/apps/bare-expo/ios/Podfile.lock
@@ -2231,7 +2231,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- - react-native-keyboard-controller (1.21.9):
+ - react-native-keyboard-controller (1.22.4):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -2243,7 +2243,7 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- - react-native-keyboard-controller/common (= 1.21.9)
+ - react-native-keyboard-controller/common (= 1.22.4)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
@@ -2254,7 +2254,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- - react-native-keyboard-controller/common (1.21.9):
+ - react-native-keyboard-controller/common (1.22.4):
- hermes-engine
- RCTRequired
- RCTTypeSafety
@@ -3487,7 +3487,7 @@ DEPENDENCIES:
- "React-Mapbuffer (from `../../../node_modules/.pnpm/react-native@0.86.0_@babel+core@7.29.7_@react-native+jest-preset@0.86.0_@babel+core@7.2_05fe3c1b42d0a26b4dba0087fc75e51f/node_modules/react-native/ReactCommon`)"
- "React-microtasksnativemodule (from `../../../node_modules/.pnpm/react-native@0.86.0_@babel+core@7.29.7_@react-native+jest-preset@0.86.0_@babel+core@7.2_05fe3c1b42d0a26b4dba0087fc75e51f/node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)"
- "React-mutationobservernativemodule (from `../../../node_modules/.pnpm/react-native@0.86.0_@babel+core@7.29.7_@react-native+jest-preset@0.86.0_@babel+core@7.2_05fe3c1b42d0a26b4dba0087fc75e51f/node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`)"
- - "react-native-keyboard-controller (from `../../../node_modules/.pnpm/react-native-keyboard-controller@1.21.9_react-native-reanimated@4.5.1_patch_hash=f1adeb_9a349fcb8493a723edbedfbafcf73f19/node_modules/react-native-keyboard-controller`)"
+ - "react-native-keyboard-controller (from `../../../node_modules/.pnpm/react-native-keyboard-controller@1.22.4_react-native-reanimated@4.5.1_patch_hash=f1adeb_5fc0483b2b4f942879a1a7d7c7eb1c5a/node_modules/react-native-keyboard-controller`)"
- "react-native-netinfo (from `../../../node_modules/.pnpm/@react-native-community+netinfo@12.0.1_patch_hash=ced0cb79848978ecc3e780a4d812d94868434_66433ebdfb3ea787f7fc42b0ed8ee206/node_modules/@react-native-community/netinfo`)"
- "react-native-pager-view (from `../../../node_modules/.pnpm/react-native-pager-view@8.0.2_react-native@0.86.0_@babel+core@7.29.7_@react-native+jest_050fee3d9ae7682571a8180ab6e58f1e/node_modules/react-native-pager-view`)"
- "react-native-safe-area-context (from `../../../node_modules/.pnpm/react-native-safe-area-context@5.7.0_react-native@0.86.0_@babel+core@7.29.7_@react-nati_c28ea83fe184def3347ae322f545fd20/node_modules/react-native-safe-area-context`)"
@@ -3904,7 +3904,7 @@ EXTERNAL SOURCES:
React-mutationobservernativemodule:
:path: "../../../node_modules/.pnpm/react-native@0.86.0_@babel+core@7.29.7_@react-native+jest-preset@0.86.0_@babel+core@7.2_05fe3c1b42d0a26b4dba0087fc75e51f/node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver"
react-native-keyboard-controller:
- :path: "../../../node_modules/.pnpm/react-native-keyboard-controller@1.21.9_react-native-reanimated@4.5.1_patch_hash=f1adeb_9a349fcb8493a723edbedfbafcf73f19/node_modules/react-native-keyboard-controller"
+ :path: "../../../node_modules/.pnpm/react-native-keyboard-controller@1.22.4_react-native-reanimated@4.5.1_patch_hash=f1adeb_5fc0483b2b4f942879a1a7d7c7eb1c5a/node_modules/react-native-keyboard-controller"
react-native-netinfo:
:path: "../../../node_modules/.pnpm/@react-native-community+netinfo@12.0.1_patch_hash=ced0cb79848978ecc3e780a4d812d94868434_66433ebdfb3ea787f7fc42b0ed8ee206/node_modules/@react-native-community/netinfo"
react-native-pager-view:
@@ -4156,7 +4156,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: ce449ccdf3b80384415b925606be8a4bdcfc65d3
React-microtasksnativemodule: 2eb3f49d0d8e77b5343455eccd057010b8d38b6b
React-mutationobservernativemodule: f0a0d5ae9b51caf7becbeabf836d716cfedb6bf2
- react-native-keyboard-controller: d4796248609d4c03cd6b5f73a9fd083d7c5f33d6
+ react-native-keyboard-controller: d9d240e9c2cfddc41c6de0f70b881cc82eeaadd6
react-native-netinfo: 2f906d5d9a639090ed1c079135d6485a95f749f1
react-native-pager-view: c9f0c94cc64041ac70f28f79527ea2ac664cf25c
react-native-safe-area-context: 6b4966397ada0f7dd481e4486a2ef936834861a1
diff --git a/apps/bare-expo/package.json b/apps/bare-expo/package.json
index 939310d494761b..31d37dfa37e0d6 100644
--- a/apps/bare-expo/package.json
+++ b/apps/bare-expo/package.json
@@ -45,6 +45,7 @@
"expo-insights": "workspace:*",
"expo-linking": "workspace:*",
"expo-location": "workspace:*",
+ "expo-media-library": "workspace:*",
"expo-navigation-bar": "workspace:*",
"expo-network-addons": "workspace:*",
"expo-notifications": "workspace:*",
@@ -62,7 +63,7 @@
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-gesture-handler": "~3.1.0",
- "react-native-keyboard-controller": "^1.21.9",
+ "react-native-keyboard-controller": "^1.22.4",
"react-native-pager-view": "8.0.2",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "5.7.0",
diff --git a/apps/expo-go/ios/Podfile.lock b/apps/expo-go/ios/Podfile.lock
index 06073a60e914c7..1e5face53b384e 100644
--- a/apps/expo-go/ios/Podfile.lock
+++ b/apps/expo-go/ios/Podfile.lock
@@ -2379,7 +2379,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - react-native-keyboard-controller (1.21.9):
+ - react-native-keyboard-controller (1.22.4):
- boost
- DoubleConversion
- fast_float
@@ -2397,7 +2397,7 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- - react-native-keyboard-controller/common (= 1.21.9)
+ - react-native-keyboard-controller/common (= 1.22.4)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
@@ -2408,7 +2408,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- - react-native-keyboard-controller/common (1.21.9):
+ - react-native-keyboard-controller/common (1.22.4):
- boost
- DoubleConversion
- fast_float
@@ -4106,7 +4106,7 @@ DEPENDENCIES:
- React-Mapbuffer (from `../../../react-native-lab/react-native/packages/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../../../react-native-lab/react-native/packages/react-native/ReactCommon/react/nativemodule/microtasks`)
- React-mutationobservernativemodule (from `../../../react-native-lab/react-native/packages/react-native/ReactCommon/react/nativemodule/mutationobserver`)
- - "react-native-keyboard-controller (from `../../../node_modules/.pnpm/react-native-keyboard-controller@1.21.9_react-native-reanimated@4.5.1_patch_hash=f1adeb_9a349fcb8493a723edbedfbafcf73f19/node_modules/react-native-keyboard-controller`)"
+ - "react-native-keyboard-controller (from `../../../node_modules/.pnpm/react-native-keyboard-controller@1.22.4_react-native-reanimated@4.5.1_patch_hash=f1adeb_5fc0483b2b4f942879a1a7d7c7eb1c5a/node_modules/react-native-keyboard-controller`)"
- "react-native-maps/Maps (from `../../../node_modules/.pnpm/react-native-maps@1.27.2_react-native-web@0.21.2_encoding@0.1.13_react-dom@19.2.3_react_07670b4144253b002775c6adf1c85a39/node_modules/react-native-maps`)"
- "react-native-netinfo (from `../../../node_modules/.pnpm/@react-native-community+netinfo@12.0.1_patch_hash=ced0cb79848978ecc3e780a4d812d94868434_66433ebdfb3ea787f7fc42b0ed8ee206/node_modules/@react-native-community/netinfo`)"
- "react-native-pager-view (from `../../../node_modules/.pnpm/react-native-pager-view@8.0.2_react-native@0.86.0_@babel+core@7.29.7_@react-native+jest_050fee3d9ae7682571a8180ab6e58f1e/node_modules/react-native-pager-view`)"
@@ -4435,7 +4435,7 @@ EXTERNAL SOURCES:
React-mutationobservernativemodule:
:path: "../../../react-native-lab/react-native/packages/react-native/ReactCommon/react/nativemodule/mutationobserver"
react-native-keyboard-controller:
- :path: "../../../node_modules/.pnpm/react-native-keyboard-controller@1.21.9_react-native-reanimated@4.5.1_patch_hash=f1adeb_9a349fcb8493a723edbedfbafcf73f19/node_modules/react-native-keyboard-controller"
+ :path: "../../../node_modules/.pnpm/react-native-keyboard-controller@1.22.4_react-native-reanimated@4.5.1_patch_hash=f1adeb_5fc0483b2b4f942879a1a7d7c7eb1c5a/node_modules/react-native-keyboard-controller"
react-native-maps:
:path: "../../../node_modules/.pnpm/react-native-maps@1.27.2_react-native-web@0.21.2_encoding@0.1.13_react-dom@19.2.3_react_07670b4144253b002775c6adf1c85a39/node_modules/react-native-maps"
react-native-netinfo:
@@ -4683,7 +4683,7 @@ SPEC CHECKSUMS:
React-Mapbuffer: 187c03c70cba972bba8786b581897d8e1ae6bdc3
React-microtasksnativemodule: e130d410da41cad9b9776c492e6f8c07ad7b34df
React-mutationobservernativemodule: 7f5f4da31e906da2100a432672c55cb2096570e1
- react-native-keyboard-controller: 93e24ebbe21551622cbb0e9c39664dc5539f943c
+ react-native-keyboard-controller: 1d5ba749e13f7392286934769bfad3ce98e4f519
react-native-maps: b5af575a371e57847c6d8490e726c08036f7c80f
react-native-netinfo: a0be8c77f8420a60ddf07eb87be1a36f02a1bd65
react-native-pager-view: 2694b7efa49588b1cc3aecb5f513871135f499cc
diff --git a/apps/expo-go/package.json b/apps/expo-go/package.json
index de6e12813776ec..16cacd939a4411 100644
--- a/apps/expo-go/package.json
+++ b/apps/expo-go/package.json
@@ -84,7 +84,7 @@
"react": "19.2.3",
"react-native": "0.86.0",
"react-native-gesture-handler": "~3.1.0",
- "react-native-keyboard-controller": "^1.21.9",
+ "react-native-keyboard-controller": "^1.22.4",
"react-native-maps": "1.27.2",
"react-native-pager-view": "8.0.2",
"react-native-reanimated": "4.5.1",
diff --git a/apps/native-component-list/package.json b/apps/native-component-list/package.json
index 5c093e861bbd18..d46df30d64f3ac 100644
--- a/apps/native-component-list/package.json
+++ b/apps/native-component-list/package.json
@@ -129,7 +129,7 @@
"react-native": "0.86.0",
"react-native-dropdown-picker": "^5.3.0",
"react-native-gesture-handler": "~3.1.0",
- "react-native-keyboard-controller": "^1.21.9",
+ "react-native-keyboard-controller": "^1.22.4",
"react-native-maps": "1.27.2",
"react-native-pager-view": "8.0.2",
"react-native-reanimated": "4.5.1",
diff --git a/apps/native-component-list/src/screens/MediaLibrary/MediaLibraryScreen.tsx b/apps/native-component-list/src/screens/MediaLibrary/MediaLibraryScreen.tsx
index 908ff8c686b871..2e5783699b96f8 100644
--- a/apps/native-component-list/src/screens/MediaLibrary/MediaLibraryScreen.tsx
+++ b/apps/native-component-list/src/screens/MediaLibrary/MediaLibraryScreen.tsx
@@ -1,3 +1,4 @@
+import SegmentedControl from '@react-native-segmented-control/segmented-control';
import * as FileSystem from 'expo-file-system/legacy';
import * as MediaLibrary from 'expo-media-library/legacy';
import { router, useFocusEffect, type NativeStackScreenProps } from 'expo-router';
@@ -19,9 +20,8 @@ import Button from '../../components/Button';
import HeadingText from '../../components/HeadingText';
import Colors from '../../constants/Colors';
import MediaLibraryCell from './MediaLibraryCell';
-
const COLUMNS = 3;
-const PAGE_SIZE = COLUMNS * 10;
+const PAGE_SIZE_OPTIONS = [30, 300, 3000, 30000] as const;
const WINDOW_SIZE = Dimensions.get('window');
type MediaTypeWithoutPairedVideo = Exclude;
@@ -60,6 +60,10 @@ type FetchState = {
assets: MediaLibrary.Asset[];
endCursor: string | null;
hasNextPage: boolean;
+ /** First loaded asset location from getAssetsAsync (iOS batch location debug). */
+ batchLocationPreview: string | null;
+ /** Duration of the most recent getAssetsAsync call, in milliseconds. */
+ lastFetchDurationMs: number | null;
};
const initialState: FetchState = {
@@ -68,6 +72,8 @@ const initialState: FetchState = {
assets: [],
endCursor: null,
hasNextPage: true,
+ batchLocationPreview: null,
+ lastFetchDurationMs: null,
};
function reducer(
@@ -178,7 +184,7 @@ export default function MediaLibraryScreen({ navigation, route }: Props) {
}
// The fetching and sorting logic is split out from the navigation and permission logic for simplicity.
-function MediaLibraryView({ navigation, route, accessPrivileges }: Props) {
+function MediaLibraryView({ route, accessPrivileges }: Props) {
const albumId = route.params?.albumId;
const albumTitle = route.params?.albumTitle;
@@ -188,13 +194,15 @@ function MediaLibraryView({ navigation, route, accessPrivileges }: Props) {
const [mediaType, setMediaType] = React.useState(
MediaLibrary.MediaType.photo
);
+ const [pageSize, setPageSize] = React.useState(PAGE_SIZE_OPTIONS[0]);
+ const [resolveWithFullInfo, setResolveWithFullInfo] = React.useState(false);
const [state, dispatch] = React.useReducer(reducer, initialState);
// Update without showing the refresh indicator whenever the sorting parameters change.
React.useEffect(() => {
dispatch({ type: 'reset', refreshing: false });
- }, [mediaType, sortBy, albumId]);
+ }, [mediaType, sortBy, albumId, pageSize, resolveWithFullInfo]);
const toggleMediaType = React.useCallback(() => {
setMediaType(mediaTypeStates[mediaType]);
@@ -204,6 +212,10 @@ function MediaLibraryView({ navigation, route, accessPrivileges }: Props) {
setSortBy(sortByStates[sortBy]);
}, [setSortBy, sortBy]);
+ const toggleResolveWithFullInfo = React.useCallback(() => {
+ setResolveWithFullInfo((current) => !current);
+ }, []);
+
const loadMoreAssets = React.useCallback(async () => {
if (
// if a fetch operation is still in progress or there are no more assets then bail out.
@@ -217,14 +229,27 @@ function MediaLibraryView({ navigation, route, accessPrivileges }: Props) {
try {
// Make a native request for assets.
+ if (!state.endCursor) {
+ dispatch({ type: 'update', refreshing: true });
+ }
+ const fetchStartedAt = performance.now();
const { assets, endCursor, hasNextPage } = await MediaLibrary.getAssetsAsync({
- first: PAGE_SIZE,
+ first: pageSize,
after: state.endCursor ?? undefined,
mediaType,
mediaSubtypes: [],
sortBy,
album: albumId,
+ resolveWithFullInfo,
});
+ const lastFetchDurationMs = Math.round(performance.now() - fetchStartedAt);
+ const firstBatchAssetLocation = assets[0]?.location;
+ const batchLocationPreview =
+ state.endCursor == null
+ ? firstBatchAssetLocation != null
+ ? `${firstBatchAssetLocation.latitude}, ${firstBatchAssetLocation.longitude}`
+ : null
+ : state.batchLocationPreview;
// Get the last asset currently in the state.
const lastAsset = state.assets[state.assets.length - 1];
@@ -239,13 +264,25 @@ function MediaLibraryView({ navigation, route, accessPrivileges }: Props) {
assets: ([] as MediaLibrary.Asset[]).concat(state.assets, assets),
endCursor,
hasNextPage,
+ lastFetchDurationMs,
+ batchLocationPreview,
});
}
} finally {
// Toggle this back to false in a finally to ensure we can reload later, even if an error ocurred.
isLoadingAssets.current = false;
}
- }, [state.endCursor, state.hasNextPage, state.assets, mediaType, sortBy, albumId]);
+ }, [
+ state.endCursor,
+ state.hasNextPage,
+ state.assets,
+ state.batchLocationPreview,
+ mediaType,
+ sortBy,
+ albumId,
+ pageSize,
+ resolveWithFullInfo,
+ ]);
// Fetch data whenever the state.fetching value is true.
React.useEffect(() => {
@@ -320,9 +357,18 @@ function MediaLibraryView({ navigation, route, accessPrivileges }: Props) {
onPress={toggleMediaType}
/>
-
- {accessPrivileges === 'limited' && (
-
+
+ Page size
+ setPageSize(Number(value))}
+ />
+
+ {accessPrivileges === 'limited' && (
-
+ )}
+
+
+ {state.lastFetchDurationMs != null && (
+
+ {`Last fetch: ${state.lastFetchDurationMs} ms (${state.assets.length} assets loaded)`}
+
+ )}
+ {state.batchLocationPreview != null && (
+
+ {`First batch asset location: ${state.batchLocationPreview}`}
+
)}
);
- }, [mediaType, albumId, albumTitle, sortBy, toggleMediaType, toggleSortBy]);
+ }, [
+ mediaType,
+ albumId,
+ albumTitle,
+ sortBy,
+ pageSize,
+ toggleMediaType,
+ toggleSortBy,
+ toggleResolveWithFullInfo,
+ resolveWithFullInfo,
+ accessPrivileges,
+ state.batchLocationPreview,
+ state.lastFetchDurationMs,
+ state.assets.length,
+ ]);
const renderFooter = React.useCallback(() => {
if (state.refreshing) {
@@ -395,6 +470,17 @@ const styles = StyleSheet.create({
button: {
marginHorizontal: 5,
},
+ pageSizeSelector: {
+ marginHorizontal: 5,
+ alignItems: 'center',
+ gap: 6,
+ },
+ pageSizeLabel: {
+ fontSize: 12,
+ },
+ pageSizeControl: {
+ minWidth: 200,
+ },
header: {
paddingTop: 0,
paddingBottom: 16,
@@ -407,6 +493,8 @@ const styles = StyleSheet.create({
marginTop: 5,
paddingVertical: 10,
flexDirection: 'row',
+ flexWrap: 'wrap',
+ rowGap: 8,
justifyContent: 'center',
alignItems: 'center',
},
@@ -418,4 +506,9 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
+ headerMetadata: {
+ marginTop: 8,
+ fontSize: 12,
+ textAlign: 'center',
+ },
});
diff --git a/apps/test-suite/tests/MediaLibrary.js b/apps/test-suite/tests/MediaLibrary.js
index ae6351485330e5..805fb4d196fb49 100644
--- a/apps/test-suite/tests/MediaLibrary.js
+++ b/apps/test-suite/tests/MediaLibrary.js
@@ -442,6 +442,28 @@ export async function test(t) {
t.expect(asset.creationTime).toBeGreaterThanOrEqual(createdAfter);
}
});
+
+ if (Platform.OS === 'android') {
+ t.it('resolveWithFullInfo returns numeric location when available', async () => {
+ const [exifFile] = await Asset.loadAsync(require('../assets/exif_data_image.jpg'));
+ const exifAsset = await MediaLibrary.createAssetAsync(exifFile.localUri, album);
+ try {
+ const { assets } = await MediaLibrary.getAssetsAsync({
+ album,
+ resolveWithFullInfo: true,
+ });
+ const asset = assets.find((a) => a.id === exifAsset.id);
+ t.expect(asset).toBeDefined();
+ t.expect(asset.exif).toBeDefined();
+ t.expect(asset.localUri).toBeDefined();
+ t.expect(asset.location).not.toBeNull();
+ t.expect(typeof asset.location.latitude).toBe('number');
+ t.expect(typeof asset.location.longitude).toBe('number');
+ } finally {
+ await MediaLibrary.deleteAssetsAsync(exifAsset);
+ }
+ });
+ }
});
t.describe('getAssetInfoAsync', () => {
diff --git a/docs/pages/eas/observe/introduction.mdx b/docs/pages/eas/observe/introduction.mdx
index bdc9e551e5c84a..d557a2b0a2d59c 100644
--- a/docs/pages/eas/observe/introduction.mdx
+++ b/docs/pages/eas/observe/introduction.mdx
@@ -17,7 +17,7 @@ import { RelatedSkills } from '~/ui/components/RelatedSkills';
import { Terminal } from '~/ui/components/Snippet';
import { VideoBoxLink } from '~/ui/components/VideoBoxLink';
-> **important** **EAS Observe** is in [Open Beta](/more/release-statuses/#beta). The first 10,000 monthly active users are free. For higher usage, contact [sales@expo.dev](mailto:sales@expo.dev).
+> **info** EAS Observe includes 10,000 monthly active users on the Free plan and 50,000 on paid plans. Beyond that, pricing is usage-based. See [Pricing](https://expo.dev/pricing) for details.
**EAS Observe** is a performance monitoring service from Expo for tracking how your app performs in production. It gives you visibility into real-world startup times, rendering performance, and user experience across different devices, networks, and conditions.
diff --git a/docs/pages/versions/unversioned/sdk/observe.mdx b/docs/pages/versions/unversioned/sdk/observe.mdx
index 543d30338f11ae..112a8ceda64033 100644
--- a/docs/pages/versions/unversioned/sdk/observe.mdx
+++ b/docs/pages/versions/unversioned/sdk/observe.mdx
@@ -4,13 +4,12 @@ description: A library that collects app performance metrics and user-defined ev
sourceCodeUrl: 'https://github.com/expo/expo/tree/main/packages/expo-observe'
packageName: 'expo-observe'
platforms: ['android', 'ios', 'tvos']
-isBeta: true
---
import APISection from '~/components/plugins/APISection';
import { APIInstallSection } from '~/components/plugins/InstallSection';
-> **important** **EAS Observe** is in [Open Beta](/more/release-statuses/#beta). The first 10,000 monthly active users are free. For higher usage, contact [sales@expo.dev](mailto:sales@expo.dev).
+> **info** EAS Observe includes 10,000 monthly active users on the Free plan and 50,000 on paid plans. Beyond that, pricing is usage-based. See [Pricing](https://expo.dev/pricing) for details.
`expo-observe` is a library that collects performance metrics and user-defined events from your app and dispatches them to [EAS Observe](/eas/observe/introduction/), a performance monitoring service from Expo, or to your preferred OpenTelemetry (OTEL)-compliant backend. It measures real-world startup performance, such as [Time to First Render (TTR)](/eas/observe/reference/metrics/#time-to-first-render-ttr) and [Time to Interactive (TTI)](/eas/observe/reference/metrics/#time-to-interactive-tti), from apps running in production.
diff --git a/docs/pages/versions/v57.0.0/sdk/observe.mdx b/docs/pages/versions/v57.0.0/sdk/observe.mdx
index def61758e6afd5..124908f13ddcfa 100644
--- a/docs/pages/versions/v57.0.0/sdk/observe.mdx
+++ b/docs/pages/versions/v57.0.0/sdk/observe.mdx
@@ -4,13 +4,12 @@ description: A library that collects app performance metrics and user-defined ev
sourceCodeUrl: 'https://github.com/expo/expo/tree/sdk-57/packages/expo-observe'
packageName: 'expo-observe'
platforms: ['android', 'ios', 'tvos']
-isBeta: true
---
import APISection from '~/components/plugins/APISection';
import { APIInstallSection } from '~/components/plugins/InstallSection';
-> **important** **EAS Observe** is in [Open Beta](/more/release-statuses/#beta). The first 10,000 monthly active users are free. For higher usage, contact [sales@expo.dev](mailto:sales@expo.dev).
+> **info** EAS Observe includes 10,000 monthly active users on the Free plan and 50,000 on paid plans. Beyond that, pricing is usage-based. See [Pricing](https://expo.dev/pricing) for details.
`expo-observe` is a library that collects performance metrics and user-defined events from your app and dispatches them to [EAS Observe](/eas/observe/introduction/), a performance monitoring service from Expo, or to your preferred OpenTelemetry (OTEL)-compliant backend. It measures real-world startup performance, such as [Time to First Render (TTR)](/eas/observe/reference/metrics/#time-to-first-render-ttr) and [Time to Interactive (TTI)](/eas/observe/reference/metrics/#time-to-interactive-tti), from apps running in production.
diff --git a/packages/expo-media-library/CHANGELOG.md b/packages/expo-media-library/CHANGELOG.md
index a8fb1cd64daeeb..f2af2d6cc52634 100644
--- a/packages/expo-media-library/CHANGELOG.md
+++ b/packages/expo-media-library/CHANGELOG.md
@@ -13,6 +13,7 @@
### 🐛 Bug fixes
+- [Android] Parallelize per-file EXIF and location reads in `getAssetsAsync` when `resolveWithFullInfo` is true, speeding up large library scans. ([#48637](https://github.com/expo/expo/pull/48637) by [@hsource](https://github.com/hsource) and [@robin-pham](https://github.com/robin-pham))
- [Android] Fix transposed `width`/`height` for rotated assets (portrait photos and videos): `Asset.getInfo()`, `getWidth()`/`getHeight()`/`getShape()` and `Query.exeForMetadata()` now honor MediaStore `ORIENTATION`, matching the legacy API. ([#48150](https://github.com/expo/expo/pull/48150) by [@oeddyo](https://github.com/oeddyo))
### 💡 Others
diff --git a/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/assets/AssetUtils.kt b/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/assets/AssetUtils.kt
index 2dfa46a8e2f7eb..d278e6fb32a795 100644
--- a/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/assets/AssetUtils.kt
+++ b/packages/expo-media-library/android/src/main/java/expo/modules/medialibrary/assets/AssetUtils.kt
@@ -19,6 +19,9 @@ import expo.modules.medialibrary.EXIF_TAGS
import expo.modules.medialibrary.MediaType
import expo.modules.medialibrary.UnableToLoadException
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
@@ -81,7 +84,7 @@ suspend fun queryAssetInfo(
* Cursor must be a result of query with [ASSET_PROJECTION] projection
*/
@Throws(IOException::class, UnsupportedOperationException::class)
-fun putAssetsInfo(
+suspend fun putAssetsInfo(
contentResolver: ContentResolver,
cursor: Cursor,
response: MutableList,
@@ -97,26 +100,29 @@ fun putAssetsInfo(
val durationIndex = cursor.getColumnIndex(MediaStore.Video.VideoColumns.DURATION)
val localUriIndex = cursor.getColumnIndex(MediaStore.Images.Media.DATA)
val albumIdIndex = cursor.getColumnIndex(MediaStore.Images.Media.BUCKET_ID)
+ val widthIndex = cursor.getColumnIndex(MediaStore.MediaColumns.WIDTH)
+ val heightIndex = cursor.getColumnIndex(MediaStore.MediaColumns.HEIGHT)
+ val orientationIndex = cursor.getColumnIndex(MediaStore.Images.Media.ORIENTATION)
if (!cursor.moveToPosition(offset)) {
return
}
+
+ // Assets that we may have to fetch more information for. We do this in parallel later since
+ // it's significantly faster.
+ val pendingAdditionalInfoAssets = mutableListOf()
var i = 0
+
while (i < limit && !cursor.isAfterLast) {
val assetId = cursor.getString(idIndex)
val path = cursor.getString(localUriIndex)
val localUri = "file://$path"
val mediaType = cursor.getInt(mediaTypeIndex)
- var exifInterface: ExifInterface? = null
- if (resolveWithFullInfo && mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) {
- try {
- exifInterface = ExifInterface(path)
- } catch (e: IOException) {
- Log.w("expo-media-library", "Could not parse EXIF tags for $localUri")
- e.printStackTrace()
- }
- }
- val (width, height) =
- getAssetDimensionsFromCursor(contentResolver, exifInterface, cursor, mediaType, localUriIndex)
+ val cursorWidth = cursor.getInt(widthIndex)
+ val cursorHeight = cursor.getInt(heightIndex)
+ val cursorOrientation = cursor.getInt(orientationIndex)
+ val dimensions = getAssetDimensionsFromCursorFast(cursor, mediaType)
+ val width = dimensions?.first ?: 0
+ val height = dimensions?.second ?: 0
val asset = Bundle().apply {
putString("id", assetId)
putString("filename", cursor.getString(filenameIndex))
@@ -129,22 +135,89 @@ fun putAssetsInfo(
putDouble("duration", cursor.getInt(durationIndex) / 1000.0)
putString("albumId", cursor.getString(albumIdIndex))
}
- if (resolveWithFullInfo && exifInterface != null) {
- getExifFullInfo(exifInterface, asset)
- val location = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- val photoUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, assetId)
- getExifLocationForUri(contentResolver, photoUri)
- } else {
- getExifLocationLegacy(exifInterface)
- }
- asset.putParcelable("location", location)
- asset.putString("localUri", localUri)
+ val missingDimensions = dimensions == null
+ // If we need to do slower I/O operations, we add them to a list so that we can do them in parallel
+ if (missingDimensions || (resolveWithFullInfo && mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE)) {
+ pendingAdditionalInfoAssets.add(
+ PendingAsset(
+ asset = asset,
+ path = path,
+ mediaType = mediaType,
+ cursorWidth = cursorWidth,
+ cursorHeight = cursorHeight,
+ cursorOrientation = cursorOrientation,
+ missingDimensions = missingDimensions
+ )
+ )
}
cursor.moveToNext()
response.add(asset)
i++
}
+
+ if (pendingAdditionalInfoAssets.isNotEmpty()) {
+ // When resolveWithFullInfo is true, per-file EXIF data (including GPS location) is read in
+ // parallel across exifReadDispatcher instead of sequentially.
+ //
+ // - EXIF location reads cost ~15ms per image sequentially. They are independent per-file I/O,
+ // so parallelizing them speeds up large queries by several times.
+ // - Cursor access is not thread-safe. Paths are copied into pending asset lists during the
+ // cursor loop before parallel file work; workers never touch the cursor.
+ withContext(exifReadDispatcher) {
+ pendingAdditionalInfoAssets.map { pending ->
+ async {
+ val localUri = "file://${pending.path}"
+ var exifInterface: ExifInterface? = null
+ if (
+ resolveWithFullInfo &&
+ pending.mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE
+ ) {
+ try {
+ exifInterface = ExifInterface(pending.path)
+ } catch (e: IOException) {
+ Log.w("expo-media-library", "Could not parse EXIF tags for $localUri")
+ e.printStackTrace()
+ }
+ }
+
+ if (pending.missingDimensions) {
+ val (width, height) = getAssetDimensionsSlow(contentResolver, exifInterface, pending)
+ pending.asset.putLong("width", width.toLong())
+ pending.asset.putLong("height", height.toLong())
+ }
+
+ if (exifInterface != null) {
+ // If we haven't already, use the fetched EXIF data to populate the dimensions.
+ // While the cursor should have the orientation, this defends against bugs when it
+ // might be missing.
+ if (!pending.missingDimensions) {
+ val (width, height) = getImageDimensionsFromExifFast(
+ exifInterface,
+ pending.cursorWidth,
+ pending.cursorHeight,
+ pending.cursorOrientation
+ )
+ pending.asset.putLong("width", width.toLong())
+ pending.asset.putLong("height", height.toLong())
+ }
+
+ getExifFullInfo(exifInterface, pending.asset)
+
+ val assetId = pending.asset.getString("id") ?: return@async
+ val location = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val photoUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, assetId)
+ getExifLocationForUri(contentResolver, photoUri)
+ } else {
+ getExifLocationLegacy(exifInterface)
+ }
+ pending.asset.putParcelable("location", location)
+ pending.asset.putString("localUri", localUri)
+ }
+ }
+ }.awaitAll()
+ }
+ }
}
fun getExifFullInfo(exifInterface: ExifInterface, response: Bundle) {
@@ -207,18 +280,13 @@ fun getExifLocationLegacy(exifInterface: ExifInterface): Bundle? {
}
/**
- * Gets image/video dimensions
- * @return Pair of integers: width and height, respectively
+ * Gets image/video dimensions from MediaStore cursor columns (no file I/O).
+ * @return Rotated dimensions, or `null` if width/height are not yet indexed.
*/
-@Throws(IOException::class)
-fun getAssetDimensionsFromCursor(
- contentResolver: ContentResolver,
- exifInterface: ExifInterface?,
+fun getAssetDimensionsFromCursorFast(
cursor: Cursor,
- mediaType: Int,
- localUriColumnIndex: Int
-): Pair {
- val uri = cursor.getString(localUriColumnIndex)
+ mediaType: Int
+): Pair? {
if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
// Fast path: read dimensions from MediaStore cursor (no file I/O).
// MediaStore populates these when the media scanner indexes the file.
@@ -233,8 +301,37 @@ fun getAssetDimensionsFromCursor(
return maybeRotateAssetSize(width, height, orientation)
}
+ return null
+ }
+
+ val widthIndex = cursor.getColumnIndex(MediaStore.MediaColumns.WIDTH)
+ val heightIndex = cursor.getColumnIndex(MediaStore.MediaColumns.HEIGHT)
+ val orientationIndex = cursor.getColumnIndex(MediaStore.Images.Media.ORIENTATION)
+ val width = cursor.getInt(widthIndex)
+ val height = cursor.getInt(heightIndex)
+ val orientation = cursor.getInt(orientationIndex)
+
+ // If the image doesn't have the required information, we can get them from Bitmap.Options
+ if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE && (width <= 0 || height <= 0)) {
+ return null
+ }
+ return maybeRotateAssetSize(width, height, orientation)
+}
+
+/**
+ * Gets image/video dimensions via file I/O when cursor columns are missing and/or EXIF orientation
+ * is needed for [maybeRotateAssetSize].
+ * @return Pair of integers: width and height, respectively
+ */
+@Throws(IOException::class)
+private fun getAssetDimensionsSlow(
+ contentResolver: ContentResolver,
+ exifInterface: ExifInterface?,
+ pending: PendingAsset
+): Pair {
+ if (pending.mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
// Slow fallback for files not yet indexed by the media scanner.
- val videoUri = Uri.parse("file://$uri")
+ val videoUri = Uri.parse("file://${pending.path}")
try {
contentResolver.openAssetFileDescriptor(videoUri, "r").use { photoDescriptor ->
MediaMetadataRetriever().use { retriever ->
@@ -252,26 +349,41 @@ fun getAssetDimensionsFromCursor(
} catch (e: NumberFormatException) {
Log.e("expo-media-library", "MediaMetadataRetriever unexpectedly returned non-integer: ${e.message}")
} catch (e: FileNotFoundException) {
- Log.e("expo-media-library", "ContentResolver failed to read $uri: ${e.message}")
+ Log.e("expo-media-library", "ContentResolver failed to read ${pending.path}: ${e.message}")
} catch (e: RuntimeException) {
Log.e("expo-media-library", "MediaMetadataRetriever finished with unexpected error: ${e.message}")
}
+ return Pair(0, 0)
}
- val widthIndex = cursor.getColumnIndex(MediaStore.MediaColumns.WIDTH)
- val heightIndex = cursor.getColumnIndex(MediaStore.MediaColumns.HEIGHT)
- val orientationIndex = cursor.getColumnIndex(MediaStore.Images.Media.ORIENTATION)
- var width = cursor.getInt(widthIndex)
- var height = cursor.getInt(heightIndex)
- var orientation = cursor.getInt(orientationIndex)
+ var width = pending.cursorWidth
+ var height = pending.cursorHeight
+ var orientation = pending.cursorOrientation
// If the image doesn't have the required information, we can get them from Bitmap.Options
- if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE && (width <= 0 || height <= 0)) {
+ if (pending.mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE && (width <= 0 || height <= 0)) {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
- BitmapFactory.decodeFile(uri, options)
+ BitmapFactory.decodeFile(pending.path, options)
width = options.outWidth
height = options.outHeight
}
+
+ return getImageDimensionsFromExifFast(exifInterface, width, height, orientation)
+}
+
+/**
+ * Applies EXIF orientation to pre-fetched image dimensions. This is fast because — it performs no
+ * file I/O. It prefers EXIF orientation when [exifInterface] is available, and otherwise falls back
+ * to [cursorOrientation] from the MediaStore cursor.
+ */
+private fun getImageDimensionsFromExifFast(
+ exifInterface: ExifInterface?,
+ width: Int,
+ height: Int,
+ cursorOrientation: Int
+): Pair {
+ var resolvedOrientation = cursorOrientation
+
if (exifInterface != null) {
val exifOrientation = exifInterface.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
@@ -282,10 +394,10 @@ fun getAssetDimensionsFromCursor(
exifOrientation == ExifInterface.ORIENTATION_TRANSPOSE ||
exifOrientation == ExifInterface.ORIENTATION_TRANSVERSE
) {
- orientation = 90
+ resolvedOrientation = 90
}
}
- return maybeRotateAssetSize(width, height, orientation)
+ return maybeRotateAssetSize(width, height, resolvedOrientation)
}
/**
@@ -313,3 +425,37 @@ fun maybeRotateAssetSize(width: Int, height: Int, orientation: Int): Pair()
putAssetsInfo(contentResolver, cursor, result, limit = 5, offset = 0, resolveWithFullInfo = false)
// assert
- verify(exactly = 0) {
- getExifFullInfo(any(), any())
- }
+ assertEquals(3, result.size)
- assertEquals(2, result.size)
+ result.forEach { asset ->
+ // no exif or detailed ddata
+ assertNull(asset.getString("localUri"))
+ assertFalse(asset.containsKey("exif"))
+ assertFalse(asset.containsKey("location"))
+ }
assertEquals(MockData.mockVideo.id.toString(), result[0].getString("id"))
assertEquals("file://${MockData.mockVideo.path}", result[0].getString("uri"))
+ assertEquals(MockData.mockVideo.width!!.toLong(), result[0].getLong("width"))
+ assertEquals(MockData.mockVideo.height!!.toLong(), result[0].getLong("height"))
- assertNull(result[0].getString("localUri"))
+ assertEquals(MockData.mockAudio.id.toString(), result[1].getString("id"))
+ assertEquals(0L, result[1].getLong("width"))
+ assertEquals(0L, result[1].getLong("height"))
+ }
+
+ @Test
+ fun `putAssetsInfo preserves cursor position for pagination`() = runTest {
+ val cursor = mockCursor(
+ arrayOf(
+ MockData.mockImage.toColumnArray(),
+ MockData.mockVideo.toColumnArray(),
+ MockData.mockAudio.toColumnArray()
+ )
+ )
+ val contentResolver = mockContentResolver(cursor)
+ val result = mutableListOf()
+
+ putAssetsInfo(contentResolver, cursor, result, limit = 2, offset = 0, resolveWithFullInfo = false)
+
+ assertEquals(2, result.size)
+ assertEquals(2, cursor.position)
+ }
+
+ @Test
+ fun `putAssetsInfo applies exif orientation over cursor orientation when fullInfo=true`() = runTest {
+ mockExifOrientation(ExifInterface.ORIENTATION_ROTATE_90)
+
+ val asset = putSingleAsset(
+ MockData.mockImage.copy(path = existingImagePath, width = 100, height = 200, orientation = 0),
+ resolveWithFullInfo = true
+ )
+
+ assertEquals(200L, asset.getLong("width"))
+ assertEquals(100L, asset.getLong("height"))
+ }
+
+ @Test
+ fun `putAssetsInfo keeps cursor orientation when exif orientation is normal`() = runTest {
+ mockExifOrientation(ExifInterface.ORIENTATION_NORMAL)
+
+ val asset = putSingleAsset(
+ MockData.mockImage.copy(path = existingImagePath, width = 100, height = 200, orientation = 90),
+ resolveWithFullInfo = true
+ )
+
+ assertEquals(200L, asset.getLong("width"))
+ assertEquals(100L, asset.getLong("height"))
+ }
+
+ @Test
+ fun `putAssetsInfo does not read exif when fullInfo=false`() = runTest {
+ mockExifOrientation(ExifInterface.ORIENTATION_ROTATE_90)
+
+ val asset = putSingleAsset(
+ MockData.mockImage.copy(width = 100, height = 200, orientation = 0),
+ resolveWithFullInfo = false
+ )
+
+ assertEquals(100L, asset.getLong("width"))
+ assertEquals(200L, asset.getLong("height"))
+ assertNull(asset.getString("localUri"))
+ assertFalse(asset.containsKey("exif"))
+ assertFalse(asset.containsKey("location"))
+ verify(exactly = 0) {
+ anyConstructed().getAttributeInt(ExifInterface.TAG_ORIENTATION, any())
+ }
+ }
+
+ @Test
+ fun `putAssetsInfo decodes dimensions when cursor dimensions are missing`() = runTest {
+ mockDecodedBounds(width = 300, height = 400)
+
+ val asset = putSingleAsset(
+ MockData.mockImage.copy(width = 0, height = 0, orientation = 0),
+ resolveWithFullInfo = false
+ )
+
+ assertEquals(300L, asset.getLong("width"))
+ assertEquals(400L, asset.getLong("height"))
+ assertNull(asset.getString("localUri"))
+ assertFalse(asset.containsKey("exif"))
+ assertFalse(asset.containsKey("location"))
+ }
+
+ @Test
+ fun `putAssetsInfo keeps decoded dimensions when fullInfo=true`() = runTest {
+ mockDecodedBounds(width = 300, height = 400)
+ mockExifOrientation(ExifInterface.ORIENTATION_ROTATE_90)
+
+ val asset = putSingleAsset(
+ MockData.mockImage.copy(path = existingImagePath, width = 0, height = 0, orientation = 0),
+ resolveWithFullInfo = true
+ )
+
+ assertEquals(400L, asset.getLong("width"))
+ assertEquals(300L, asset.getLong("height"))
}
@Test
@@ -85,6 +196,38 @@ internal class AssetUtilsTests {
assertEquals(swappedDimensions, rotated_m90)
}
+ private suspend fun putSingleAsset(mockAsset: MockAsset, resolveWithFullInfo: Boolean): Bundle {
+ val cursor = mockCursor(arrayOf(mockAsset.toColumnArray()))
+ val contentResolver = mockContentResolver(cursor)
+ every { contentResolver.openInputStream(any()) } returns null
+
+ val result = mutableListOf()
+ putAssetsInfo(contentResolver, cursor, result, limit = 1, offset = 0, resolveWithFullInfo)
+
+ assertEquals(1, result.size)
+ return result.single()
+ }
+
+ private fun mockExifOrientation(exifOrientation: Int) {
+ mockkConstructor(ExifInterface::class)
+ every { anyConstructed().getAttribute(any()) } returns null
+ every { anyConstructed().latLong } returns null
+ every {
+ anyConstructed().getAttributeInt(ExifInterface.TAG_ORIENTATION, any())
+ } returns exifOrientation
+ }
+
+ private fun mockDecodedBounds(width: Int, height: Int) {
+ mockkStatic(BitmapFactory::class)
+ every { BitmapFactory.decodeFile(any(), any()) } answers {
+ secondArg().apply {
+ outWidth = width
+ outHeight = height
+ }
+ null
+ }
+ }
+
@RunWith(RobolectricTestRunner::class)
class ExifTests {
@Test
diff --git a/packages/expo-media-library/android/src/test/java/expo/modules/medialibrary/assets/GetAssetInfoTests.kt b/packages/expo-media-library/android/src/test/java/expo/modules/medialibrary/assets/GetAssetInfoTests.kt
index d63152517cdca6..d5a7d65ff1986b 100644
--- a/packages/expo-media-library/android/src/test/java/expo/modules/medialibrary/assets/GetAssetInfoTests.kt
+++ b/packages/expo-media-library/android/src/test/java/expo/modules/medialibrary/assets/GetAssetInfoTests.kt
@@ -3,7 +3,6 @@ package expo.modules.medialibrary.assets
import android.os.Bundle
import android.provider.MediaStore
import expo.modules.medialibrary.AssetQueryException
-import expo.modules.medialibrary.MediaLibraryUtils
import expo.modules.medialibrary.MockContext
import expo.modules.medialibrary.MockData
import expo.modules.medialibrary.UnableToLoadException
@@ -12,7 +11,6 @@ import expo.modules.medialibrary.mockContentResolverForResult
import expo.modules.medialibrary.throwableContentResolver
import io.mockk.clearAllMocks
import io.mockk.coEvery
-import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -94,8 +92,8 @@ internal class GetAssetInfoTests {
)
)
- mockkStatic(MediaLibraryUtils::class)
- every {
+ mockkStatic(::putAssetsInfo)
+ coEvery {
putAssetsInfo(any(), any(), any(), any(), any(), any())
} just runs
diff --git a/packages/expo-media-library/android/src/test/java/expo/modules/medialibrary/assets/GetAssetsTest.kt b/packages/expo-media-library/android/src/test/java/expo/modules/medialibrary/assets/GetAssetsTest.kt
index 272101ecf83c86..bd97f06baed55f 100644
--- a/packages/expo-media-library/android/src/test/java/expo/modules/medialibrary/assets/GetAssetsTest.kt
+++ b/packages/expo-media-library/android/src/test/java/expo/modules/medialibrary/assets/GetAssetsTest.kt
@@ -8,6 +8,7 @@ import expo.modules.medialibrary.UnableToLoadException
import expo.modules.medialibrary.mockContentResolver
import expo.modules.medialibrary.mockContentResolverForResult
import expo.modules.medialibrary.throwableContentResolver
+import io.mockk.coEvery
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.just
@@ -48,7 +49,7 @@ internal class GetAssetsTest {
every { getQueryFromOptions(any()) } returns GetAssetsQuery(selection = "", order = "", limit = 10.0, offset = 0)
mockkStatic(::putAssetsInfo)
- every { putAssetsInfo(any(), any(), any(), any(), any(), any()) } just runs
+ coEvery { putAssetsInfo(any(), any(), any(), any(), any(), any()) } just runs
}
@After
diff --git a/packages/expo-media-library/src/legacy/MediaLibrary.ts b/packages/expo-media-library/src/legacy/MediaLibrary.ts
index 2d3a2d76e3b662..2ae04cc6d2ff64 100644
--- a/packages/expo-media-library/src/legacy/MediaLibrary.ts
+++ b/packages/expo-media-library/src/legacy/MediaLibrary.ts
@@ -121,6 +121,11 @@ export type Asset = {
* @platform android
*/
albumId?: string;
+ /**
+ * GPS location if available. On Android, `getAssetsAsync` includes this field for image
+ * assets only when you pass `resolveWithFullInfo: true`.
+ */
+ location?: Location;
};
// @needsAudit
@@ -322,8 +327,12 @@ export type AssetsOptions = {
*/
createdBefore?: Date | number;
/**
- * Whether to resolve full info for the assets during the query.
- * This is useful to get the full EXIF data for images. It can fix the orientation of the image.
+ * Whether to resolve full EXIF metadata for image assets during the query.
+ * When enabled, Android resolves EXIF data (including orientation) and GPS location for image assets.
+ *
+ * > **Note:** On Android, enabling this option significantly increases request time (~5×),
+ * > because the library fetches EXIF and location data per image.
+ *
* @default false
* @platform android
*/
@@ -690,7 +699,8 @@ export async function deleteAssetsAsync(assets: AssetRef[] | AssetRef): Promise<
// @needsAudit
/**
- * Provides more information about an asset, including GPS location, local URI and EXIF metadata.
+ * Provides more information about an asset, including GPS location, local URI, and EXIF metadata.
+ * On Android, the library resolves location and EXIF data for image assets only.
* For better performance, prefer using individual getters such as `asset.getLocation()` or `asset.getExif()` to fetch only the data you need.
* @param asset An [Asset](#asset) or its ID.
* @param options
@@ -850,8 +860,14 @@ export async function deleteAlbumsAsync(
// @needsAudit
/**
* Fetches a page of assets matching the provided criteria.
+ * Returned assets may include a `location` field when GPS metadata is available.
+ * On Android, pass `resolveWithFullInfo: true` to resolve location and full EXIF data for image assets only.
+ *
+ * > **Note:** On Android, `resolveWithFullInfo: true` significantly increases request time (~5×),
+ * > because the library fetches EXIF and location data per image.
+ *
* @param assetsOptions
- * @return A promise that fulfils with to [`PagedInfo`](/versions/latest/sdk/media-library-legacy/#pagedinfo) object with array of [`Asset`](#asset)s.
+ * @return A promise that fulfils with a [`PagedInfo`](/versions/latest/sdk/media-library-legacy/#pagedinfo) object with an array of [`Asset`](#asset)s.
*/
export async function getAssetsAsync(assetsOptions: AssetsOptions = {}): Promise> {
if (!MediaLibrary.getAssetsAsync) {
diff --git a/packages/expo-widgets/CHANGELOG.md b/packages/expo-widgets/CHANGELOG.md
index aede8a766bf9dd..95625da0b0f8cc 100644
--- a/packages/expo-widgets/CHANGELOG.md
+++ b/packages/expo-widgets/CHANGELOG.md
@@ -24,6 +24,7 @@
### 🐛 Bug fixes
+- [iOS] Fix XCFramework precompilation failing on the unguarded `ActivityViewContext` parameter of `getLiveActivityEnvironment`, which requires iOS 16.1. ([#49076](https://github.com/expo/expo/pull/49076) by [@brentvatne](https://github.com/brentvatne))
- [iOS] Fix `LiveActivityFactory.getInstances()` returning live activities that belong to other factories or that have already ended. ([#48489](https://github.com/expo/expo/pull/48489) by [@huextrat](https://github.com/huextrat))
- [iOS] Deliver the Live Activity `start()` URL to the Lock Screen banner, and scope it to the activity that set it instead of storing it globally per factory name. ([#48489](https://github.com/expo/expo/pull/48489) by [@huextrat](https://github.com/huextrat))
- [Android] Add 16KB page size support. ([#47135](https://github.com/expo/expo/pull/47135) by [@jakex7](https://github.com/jakex7))
diff --git a/packages/expo-widgets/ios/Widgets/Utils.swift b/packages/expo-widgets/ios/Widgets/Utils.swift
index 501e20e5a361ff..89d11099d5b722 100644
--- a/packages/expo-widgets/ios/Widgets/Utils.swift
+++ b/packages/expo-widgets/ios/Widgets/Utils.swift
@@ -119,20 +119,20 @@ public func getWidgetEnvironment(environment: EnvironmentValues) -> [String: Any
return env
}
+// ActivityViewContext requires iOS 16.1. Without the availability attribute, precompiling
+// this package fails: the SwiftPM prebuild targets iOS 16.0 (spm.config.json), unlike the
+// podspec (16.4). Both callers live in @available(iOS 16.1, *) views.
+@available(iOS 16.1, *)
func getLiveActivityEnvironment(for environment: EnvironmentValues, in context: ActivityViewContext) -> [String: Any] {
var env: [String: Any] = [
"colorScheme": "\(environment.colorScheme)"
]
- if #available(iOS 16.0, *) {
- env["isLuminanceReduced"] = environment.isLuminanceReduced
- }
+ env["isLuminanceReduced"] = environment.isLuminanceReduced
+ env["isActivityFullscreen"] = environment.isActivityFullscreen
if #available(iOS 16.2, *) {
env["isStale"] = context.isStale
}
- if #available(iOS 16.1, *) {
- env["isActivityFullscreen"] = environment.isActivityFullscreen
- }
if #available(iOS 18.0, *) {
env["isActivityUpdateReduced"] = environment.isActivityUpdateReduced
env["activityFamily"] = "\(environment.activityFamily)"
diff --git a/packages/expo/bundledNativeModules.json b/packages/expo/bundledNativeModules.json
index 9eefb92e000422..877cb82e896a56 100644
--- a/packages/expo/bundledNativeModules.json
+++ b/packages/expo/bundledNativeModules.json
@@ -103,7 +103,7 @@
"react-native": "0.86.2",
"react-native-web": "~0.21.0",
"react-native-gesture-handler": "~3.1.0",
- "react-native-keyboard-controller": "1.21.9",
+ "react-native-keyboard-controller": "1.22.4",
"react-native-maps": "1.27.2",
"react-native-pager-view": "8.0.2",
"react-native-worklets": "0.10.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 77d18a0c4d53ca..67c06a169be1bf 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -171,6 +171,9 @@ importers:
expo-location:
specifier: workspace:*
version: link:../../packages/expo-location
+ expo-media-library:
+ specifier: workspace:*
+ version: link:../../packages/expo-media-library
expo-navigation-bar:
specifier: workspace:*
version: link:../../packages/expo-navigation-bar
@@ -223,8 +226,8 @@ importers:
specifier: ~3.1.0
version: 3.1.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
react-native-keyboard-controller:
- specifier: ^1.21.9
- version: 1.21.9(react-native-reanimated@4.5.1(patch_hash=f1adeb1c26dfb66311eb34b353a16b2ae27f5a95a65e9d52e683a60af0e8b18e)(react-native-worklets@0.10.1(patch_hash=b72ac0fc85273472ec4b8974adb8d4eba75672291aa7501270a7b08e58ba7dec)(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
+ specifier: ^1.22.4
+ version: 1.22.4(react-native-reanimated@4.5.1(patch_hash=f1adeb1c26dfb66311eb34b353a16b2ae27f5a95a65e9d52e683a60af0e8b18e)(react-native-worklets@0.10.1(patch_hash=b72ac0fc85273472ec4b8974adb8d4eba75672291aa7501270a7b08e58ba7dec)(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
react-native-pager-view:
specifier: 8.0.2
version: 8.0.2(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
@@ -659,8 +662,8 @@ importers:
specifier: ~3.1.0
version: 3.1.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
react-native-keyboard-controller:
- specifier: ^1.21.9
- version: 1.21.9(react-native-reanimated@4.5.1(patch_hash=f1adeb1c26dfb66311eb34b353a16b2ae27f5a95a65e9d52e683a60af0e8b18e)(react-native-worklets@0.10.1(patch_hash=b72ac0fc85273472ec4b8974adb8d4eba75672291aa7501270a7b08e58ba7dec)(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
+ specifier: ^1.22.4
+ version: 1.22.4(react-native-reanimated@4.5.1(patch_hash=f1adeb1c26dfb66311eb34b353a16b2ae27f5a95a65e9d52e683a60af0e8b18e)(react-native-worklets@0.10.1(patch_hash=b72ac0fc85273472ec4b8974adb8d4eba75672291aa7501270a7b08e58ba7dec)(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
react-native-maps:
specifier: 1.27.2
version: 1.27.2(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
@@ -1088,8 +1091,8 @@ importers:
specifier: ~3.1.0
version: 3.1.0(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
react-native-keyboard-controller:
- specifier: ^1.21.9
- version: 1.21.9(react-native-reanimated@4.5.1(patch_hash=f1adeb1c26dfb66311eb34b353a16b2ae27f5a95a65e9d52e683a60af0e8b18e)(react-native-worklets@0.10.1(patch_hash=b72ac0fc85273472ec4b8974adb8d4eba75672291aa7501270a7b08e58ba7dec)(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
+ specifier: ^1.22.4
+ version: 1.22.4(react-native-reanimated@4.5.1(patch_hash=f1adeb1c26dfb66311eb34b353a16b2ae27f5a95a65e9d52e683a60af0e8b18e)(react-native-worklets@0.10.1(patch_hash=b72ac0fc85273472ec4b8974adb8d4eba75672291aa7501270a7b08e58ba7dec)(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
react-native-maps:
specifier: 1.27.2
version: 1.27.2(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3)
@@ -13848,8 +13851,8 @@ packages:
react: '*'
react-native: 0.86.0
- react-native-keyboard-controller@1.21.9:
- resolution: {integrity: sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w==}
+ react-native-keyboard-controller@1.22.4:
+ resolution: {integrity: sha512-2by3oJBQqKH9UAydlBeiEpivxIyTbt345bmverLym7vbEp4NXckWQLF0STnVuXYccRak5/sd3wMYDJPhv/9+3g==}
peerDependencies:
react: '*'
react-native: 0.86.0
@@ -23391,7 +23394,7 @@ snapshots:
react: 19.2.3
react-native: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)
- react-native-keyboard-controller@1.21.9(react-native-reanimated@4.5.1(patch_hash=f1adeb1c26dfb66311eb34b353a16b2ae27f5a95a65e9d52e683a60af0e8b18e)(react-native-worklets@0.10.1(patch_hash=b72ac0fc85273472ec4b8974adb8d4eba75672291aa7501270a7b08e58ba7dec)(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3):
+ react-native-keyboard-controller@1.22.4(react-native-reanimated@4.5.1(patch_hash=f1adeb1c26dfb66311eb34b353a16b2ae27f5a95a65e9d52e683a60af0e8b18e)(react-native-worklets@0.10.1(patch_hash=b72ac0fc85273472ec4b8974adb8d4eba75672291aa7501270a7b08e58ba7dec)(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-native: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3)