Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Query,
requestPermissionsAsync,
MediaSubtype,
AssetUriVersion,
} from 'expo-media-library';
import { useVideoPlayer, VideoView } from 'expo-video';
import { useEffect, useState } from 'react';
Expand Down Expand Up @@ -38,6 +39,8 @@ const AssetScreen = () => {
const [orientation, setOrientation] = useState<number | null | undefined>(undefined);
const [isNetworkAsset, setIsNetworkAsset] = useState<boolean | undefined>(undefined);
const [pairedVideoUri, setPairedVideoUri] = useState<string | null | undefined>(undefined);
const [uriVersions, setUriVersions] = useState<Record<AssetUriVersion, string> | null>(null);
const [hasExplainedUriVersions, setHasExplainedUriVersions] = useState(false);
const [testState, setTestState] = useState<TestState>(TestState.START);

const isVideo = assetInfo?.mediaType === MediaType.VIDEO;
Expand Down Expand Up @@ -152,6 +155,32 @@ const AssetScreen = () => {
}
};

// The Asset is re-instantiated because it caches its PHAsset, so an instance created before the
// edit would resolve a stale snapshot.
const handleCompareUriVersions = async () => {
if (!asset) {
return;
}
if (!hasExplainedUriVersions) {
setHasExplainedUriVersions(true);
Alert.alert(
'Edit the asset first',
'Open the Photos app, edit this asset and save over the original, then tap this button again. The two URIs should then point to different files.'
);
return;
}
try {
const freshAsset = new Asset(asset.id);
const current = await freshAsset.getUri({ version: AssetUriVersion.CURRENT });
const original = await freshAsset.getUri({ version: AssetUriVersion.ORIGINAL });

setUriVersions({ [AssetUriVersion.CURRENT]: current, [AssetUriVersion.ORIGINAL]: original });
} catch (e) {
console.error('Error comparing uri versions:', e);
Alert.alert('Error', 'Unable to resolve both uri versions.');
}
};

const downloadFile = async (type: 'image' | 'video'): Promise<File> => {
try {
const dir = new Directory(Paths.cache, screenName);
Expand Down Expand Up @@ -238,6 +267,17 @@ const AssetScreen = () => {
{pairedVideoUri !== undefined ? (pairedVideoUri ?? 'N/A') : 'N/A'}
</Text>
)}
{uriVersions && (
<>
<Text style={styles.infoText}>
<Text style={styles.bold}>Current URI:</Text> {uriVersions[AssetUriVersion.CURRENT]}
</Text>
<Text style={styles.infoText}>
<Text style={styles.bold}>Original URI:</Text>{' '}
{uriVersions[AssetUriVersion.ORIGINAL]}
</Text>
</>
)}
</ScrollView>
</View>
);
Expand Down Expand Up @@ -283,6 +323,11 @@ const AssetScreen = () => {
{assetInfo?.isFavorite ? 'Unmark Favorite' : 'Mark Favorite'}
</Text>
</Pressable>
{Platform.OS === 'ios' && (
<Pressable style={styles.primaryButton} onPress={handleCompareUriVersions}>
<Text style={styles.primaryButtonText}>Compare URI Versions</Text>
</Pressable>
)}
</View>
{renderAssetInfo()}
</>
Expand All @@ -308,7 +353,7 @@ const styles = StyleSheet.create({
buttonContainer: {
marginVertical: 20,
gap: 20,
flexDirection: 'row',
flexDirection: 'column',
justifyContent: 'space-evenly',
},
statusText: {
Expand Down
20 changes: 20 additions & 0 deletions apps/test-suite/tests/MediaLibraryNext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Query,
MediaType,
AssetField,
AssetUriVersion,
addListener,
removeAllListeners,
} from 'expo-media-library';
Expand Down Expand Up @@ -494,6 +495,16 @@ export async function test(t: any) {
t.expect(uri.toLowerCase()).toMatch(/\.png/);
});

// the fixture has no edits, so both renditions exist. Their paths are not compared:
// PhotoKit may back identical content with different files.
t.it('resolves both uri versions', async () => {
const asset = await createImageAsset(pngFileLocalUri);
const current = await asset.getUri({ version: AssetUriVersion.CURRENT });
const original = await asset.getUri({ version: AssetUriVersion.ORIGINAL });
t.expect(current.toLowerCase()).toMatch(/\.png/);
t.expect(original.toLowerCase()).toMatch(/\.png/);
});

t.it('returns positive width', async () => {
const asset = await createImageAsset(pngFileLocalUri);
const width = await asset.getWidth();
Expand Down Expand Up @@ -590,6 +601,15 @@ export async function test(t: any) {
t.expect(uri.toLowerCase()).toMatch(/\.mp4/);
});

// an unedited video resolves to the same file either way. Whether an edited one resolves
// to its edited render can only be checked by hand, since a test cannot edit in Photos.
t.it('resolves both uri versions', async () => {
const current = await videoAsset.getUri({ version: AssetUriVersion.CURRENT });
const original = await videoAsset.getUri({ version: AssetUriVersion.ORIGINAL });
t.expect(current.toLowerCase()).toMatch(/\.mp4/);
t.expect(original.toLowerCase()).toMatch(/\.mp4/);
});

t.it('returns positive width', async () => {
const width = await videoAsset.getWidth();
t.expect(width).toBeGreaterThan(0);
Expand Down
3 changes: 3 additions & 0 deletions packages/expo-media-library/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

### 馃洜 Breaking changes

- [iOS] `Asset.getUri()` and `AssetInfo.uri` now resolve a video to the version currently shown in the Photos app instead of the originally captured file. Pass `version: AssetUriVersion.ORIGINAL` to `getUri()` to keep the previous behavior. Note that only `getUri()` accepts the option; `AssetInfo.uri` from `getInfo()` always resolves the current version. ([#48640](https://github.com/expo/expo/pull/48640) by [@barthap](https://github.com/barthap))

### 馃帀 New features

- [iOS] Add a `version` option to `Asset.getUri()` for choosing between the current and the original rendition of an asset. ([#48640](https://github.com/expo/expo/pull/48640) by [@barthap](https://github.com/barthap))
- [Android] Add `PhotographicSensitivity` to returned EXIF metadata. ([#47222](https://github.com/expo/expo/pull/47222) by [@Wenszel](https://github.com/Wenszel))

### 馃悰 Bug fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import expo.modules.medialibrary.next.permissions.MediaStorePermissionsDelegate
import expo.modules.medialibrary.next.permissions.SystemPermissionsDelegate
import expo.modules.medialibrary.next.permissions.enums.GranularPermission
import expo.modules.medialibrary.next.records.AssetField
import expo.modules.medialibrary.next.records.AssetUriOptions
import expo.modules.medialibrary.next.observers.MediaStoreObserverManager
import expo.modules.medialibrary.next.records.SortDescriptor

Expand Down Expand Up @@ -168,7 +169,7 @@ class MediaLibraryNextModule : Module() {
self.getShape()
}

AsyncFunction("getUri") Coroutine { self: Asset ->
AsyncFunction("getUri") Coroutine { self: Asset, _: AssetUriOptions? ->
self.getUri()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package expo.modules.medialibrary.next.records

import expo.modules.kotlin.records.Record
import expo.modules.kotlin.types.OptimizedRecord

// This record is iOS only
@OptimizedRecord
class AssetUriOptions : Record
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ public final class MediaLibraryNextModule: Module {
try await this.getShape()
}

AsyncFunction("getUri") { (this: Asset) in
try await this.getUri()
AsyncFunction("getUri") { (this: Asset, options: AssetUriOptions?) in
try await this.getUri(options: options ?? AssetUriOptions())
}

AsyncFunction("getWidth") { (this: Asset) in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,16 +127,16 @@ class Asset: SharedObject {
guard try await getMediaType() == MediaTypeNext.IMAGE else {
return [:]
}
let uri = try await UriExtractor.extract(from: phAsset)
let uri = try await UriExtractor.extract(from: phAsset, version: .CURRENT)
guard let ciImage = CIImage(contentsOf: uri) else {
return [:]
}
return ciImage.properties
}

func getUri() async throws -> String {
func getUri(options: AssetUriOptions) async throws -> String {
let phAsset = try await requirePHAsset()
return try await assetMapper.mapUri(phAsset)
return try await assetMapper.mapUri(phAsset, version: options.version)
}

func getInfo() async throws -> AssetInfo {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class AssetMapper {
id: "ph://\(phAsset.localIdentifier)",
creationTime: mapCreationTime(phAsset.creationDate),
duration: mapDuration(phAsset.duration),
uri: try await mapUri(phAsset),
uri: try await mapUri(phAsset, version: .CURRENT),
filename: try mapFilename(phAsset),
height: phAsset.pixelHeight,
width: phAsset.pixelWidth,
Expand Down Expand Up @@ -39,8 +39,8 @@ class AssetMapper {
return duration > 0 ? Int(duration * 1000) : nil
}

func mapUri(_ phAsset: PHAsset) async throws -> String {
return try await UriExtractor.extract(from: phAsset).absoluteString
func mapUri(_ phAsset: PHAsset, version: AssetUriVersion) async throws -> String {
return try await UriExtractor.extract(from: phAsset, version: version).absoluteString
}

func mapFilename(_ phAsset: PHAsset) throws -> String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ExpoModulesCore

struct AssetUriOptions: Record {
@Field var version: AssetUriVersion = .CURRENT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Photos
import ExpoModulesCore

enum AssetUriVersion: String, Enumerable {
case CURRENT = "current"
case ORIGINAL = "original"

func toPHVideoRequestOptionsVersion() -> PHVideoRequestOptionsVersion {
switch self {
case .CURRENT: return .current
case .ORIGINAL: return .original
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,19 @@ import Photos
import AVFoundation

class UriExtractor {
static func extract(from phAsset: PHAsset) async throws -> URL {
static func extract(from phAsset: PHAsset, version: AssetUriVersion) async throws -> URL {
switch phAsset.mediaType {
case .image:
return try await extract(fromImage: phAsset)
return try await extract(fromImage: phAsset, version: version)
case .video:
return try await extract(fromVideo: phAsset)
return try await extract(fromVideo: phAsset, version: version)
default:
throw FailedToExtractUri("Unsupported media type")
}
}

private static func extract(fromImage phAsset: PHAsset) async throws -> URL {
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
let result = try await phAsset.requestContentEditingInput(options: options)
private static func extract(fromImage phAsset: PHAsset, version: AssetUriVersion) async throws -> URL {
let result = try await phAsset.requestContentEditingInput(options: imageRequestOptions(for: version))
guard let contentEditingInput = result.input else {
throw FailedToExtractUri("Missing content editing input for image")
}
Expand All @@ -26,12 +24,29 @@ class UriExtractor {
return url
}

private static func extract(fromVideo phAsset: PHAsset) async throws -> URL {
// Photos renders the edits into the image it returns, unless we say we can handle the
// adjustment data ourselves. Then it returns the image those edits were applied to.
static func imageRequestOptions(for version: AssetUriVersion) -> PHContentEditingInputRequestOptions {
let options = PHContentEditingInputRequestOptions()
options.isNetworkAccessAllowed = true
if version == .ORIGINAL {
options.canHandleAdjustmentData = { _ in true }
}
return options
}

static func videoRequestOptions(for version: AssetUriVersion) -> PHVideoRequestOptions {
let options = PHVideoRequestOptions()
options.version = .original
options.version = version.toPHVideoRequestOptionsVersion()
options.isNetworkAccessAllowed = true
// without this an asset stored in iCloud can come back downscaled
options.deliveryMode = .highQualityFormat
return options
}

private static func extract(fromVideo phAsset: PHAsset, version: AssetUriVersion) async throws -> URL {
let result = try await PHImageManager.default()
.requestAVAsset(forVideo: phAsset, options: options)
.requestAVAsset(forVideo: phAsset, options: videoRequestOptions(for: version))
guard let avAsset = result.asset else {
throw FailedToExtractUri("Missing AVAsset for video")
}
Expand Down
2 changes: 2 additions & 0 deletions packages/expo-media-library/src/next/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ export {

export {
AssetField,
AssetUriVersion,
MediaSubtype,
MediaType,
type Shape,
type Location,
type AssetFieldValueMap,
type AssetInfo,
type AssetUriOptions,
type GranularPermission,
type MediaLibraryAssetsChangeEvent,
type MediaTypeFilter,
Expand Down
19 changes: 16 additions & 3 deletions packages/expo-media-library/src/next/js/AssetAlbum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { UnavailabilityError } from 'expo';
import { Platform } from 'react-native';

import { NativeAsset, NativeAlbum } from '../native';
import type { AssetInfo, Location, MediaSubtype, MediaType, Shape } from '../types';
import type {
AssetInfo,
AssetUriOptions,
Location,
MediaSubtype,
MediaType,
Shape,
} from '../types';

// Asset and Album construct each other, so their implementations live together to avoid Metro require-cycle warnings

Expand Down Expand Up @@ -84,10 +91,16 @@ export class Asset {

/**
* Gets the asset URI.
*
* On iOS this resolves to the asset as it currently appears in the Photos app. Pass
* `version: AssetUriVersion.ORIGINAL` to get the file the edits were applied to instead.
* The option is ignored on Android. Note that resolving the current version of a slow-motion
* or trimmed video exports a file, so it takes longer than resolving the original.
* @param options - Selects which version of the asset to resolve.
* @returns A promise resolving to the asset URI.
*/
getUri(): Promise<string> {
return this.nativeAsset.getUri();
getUri(options?: AssetUriOptions): Promise<string> {
return this.nativeAsset.getUri(options);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
AssetField,
AssetFieldValueMap,
AssetMetadata,
AssetUriOptions,
GranularPermission,
MediaTypeFilter,
MediaLibraryAssetsChangeEvent,
Expand Down Expand Up @@ -70,7 +71,7 @@ class NativeAssetWeb implements NativeAssetClass {
getShape() {
return unavailable('Asset.getShape');
}
getUri() {
getUri(_options?: AssetUriOptions) {
return unavailable('Asset.getUri');
}
getWidth() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import type { AssetInfo, Location, Shape, MediaSubtype, MediaType } from '../../types';
import type {
AssetInfo,
AssetUriOptions,
Location,
Shape,
MediaSubtype,
MediaType,
} from '../../types';
import type { NativeAlbumClass } from './NativeAlbumClass.types';

export declare class NativeAssetClass {
Expand All @@ -15,7 +22,7 @@ export declare class NativeAssetClass {
getOrientation(): Promise<number | null>;
getModificationTime(): Promise<number | null>;
getShape(): Promise<Shape | null>;
getUri(): Promise<string>;
getUri(options?: AssetUriOptions): Promise<string>;
getWidth(): Promise<number>;
getInfo(): Promise<AssetInfo>;
getAlbums(): Promise<NativeAlbumClass[]>;
Expand Down
Loading
Loading