diff --git a/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts b/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts new file mode 100644 index 000000000..7cf113bed --- /dev/null +++ b/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts @@ -0,0 +1,103 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; +import { PhotoAssetFetchService } from '../../../services/photos/PhotoAssetFetchService'; +import { CloudPhotoItem, PhotoItem } from '../../PhotosScreen/types'; +import { usePreviewSource } from './usePreviewSource'; + +jest.mock('../../../services/photos/PhotoAssetFetchService', () => ({ + PhotoAssetFetchService: { fetchPlaybackUri: jest.fn() }, +})); + +jest.mock('src/services/common', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const mockFetchPlaybackUri = PhotoAssetFetchService.fetchPlaybackUri as jest.Mock; + +const makeLocalItem = (overrides: Partial = {}): PhotoItem => ({ + id: 'ABCD-1234/L0/001', + type: 'local', + uri: 'ph://ABCD-1234/L0/001', + mediaType: 'photo', + createdAt: Date.now(), + backupState: 'backed', + ...overrides, +}); + +const makeCloudItem = (overrides: Partial = {}): CloudPhotoItem => ({ + id: 'cloud-uuid-1', + type: 'cloud-only', + mediaType: 'photo', + fileName: 'photo.jpg', + thumbnailPath: '/cache/thumb.jpg', + thumbnailBucketId: null, + thumbnailBucketFile: null, + thumbnailType: null, + deviceId: 'device-1', + createdAt: Date.now(), + ...overrides, +}); + +beforeEach(() => { + jest.clearAllMocks(); + mockFetchPlaybackUri.mockResolvedValue('file:///dcim/photo.jpg'); +}); + +describe('usePreviewSource — uri resolution', () => { + test('when the hook renders for an item, then fetchPlaybackUri is called and its result is returned', async () => { + mockFetchPlaybackUri.mockResolvedValue('ph://ABCD-1234/L0/001'); + const item = makeLocalItem({ mediaType: 'video' }); + + const { result } = renderHook(() => usePreviewSource(item, false)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.uri).toBe('ph://ABCD-1234/L0/001'); + expect(mockFetchPlaybackUri).toHaveBeenCalledTimes(1); + }); + + test('when fetchPlaybackUri returns null, then uri is null', async () => { + mockFetchPlaybackUri.mockResolvedValue(null); + const item = makeLocalItem(); + + const { result } = renderHook(() => usePreviewSource(item, false)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.uri).toBeNull(); + }); + + test('when a cloud item is previewed, then fetchPlaybackUri is called and its result is returned', async () => { + mockFetchPlaybackUri.mockResolvedValue('file:///cache/photo_preview/cloud-uuid-1.jpg'); + const item = makeCloudItem(); + + const { result } = renderHook(() => usePreviewSource(item, false)); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.uri).toBe('file:///cache/photo_preview/cloud-uuid-1.jpg'); + expect(mockFetchPlaybackUri).toHaveBeenCalledTimes(1); + }); + + test('when scrubbing is active, then fetchPlaybackUri is not called', () => { + const item = makeCloudItem(); + + renderHook(() => usePreviewSource(item, true)); + + expect(mockFetchPlaybackUri).not.toHaveBeenCalled(); + }); +}); + +describe('usePreviewSource — thumbnailUri', () => { + test('when item is local, then thumbnailUri is the item ph:// uri', () => { + const item = makeLocalItem({ uri: 'ph://ABCD-1234/L0/001' }); + + const { result } = renderHook(() => usePreviewSource(item, false)); + + expect(result.current.thumbnailUri).toBe('ph://ABCD-1234/L0/001'); + }); + + test('when item is cloud-only, then thumbnailUri is the cached thumbnail path', () => { + const item = makeCloudItem({ thumbnailPath: '/cache/thumb.jpg' }); + + const { result } = renderHook(() => usePreviewSource(item, false)); + + expect(result.current.thumbnailUri).toBe('/cache/thumb.jpg'); + }); +}); diff --git a/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts b/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts index 8d6a37692..589a425ee 100644 --- a/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts +++ b/src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts @@ -28,7 +28,7 @@ export const usePreviewSource = (item: TimelinePhotoItem, isScrubbing: boolean): setIsLoading(true); logger.info(`[usePreviewSource] fetching asset — id: ${item.id}, type: ${item.type}, mediaType: ${item.mediaType}`); - PhotoAssetFetchService.fetchUri(item, controller.signal).then((fullUri) => { + PhotoAssetFetchService.fetchPlaybackUri(item, controller.signal).then((fullUri) => { if (!controller.signal.aborted) { logger.info(`[usePreviewSource] asset ready — id: ${item.id}, uri: ${fullUri ?? 'null'}`); setUri(fullUri ?? null); diff --git a/src/services/common/uri/uriHelpers.spec.ts b/src/services/common/uri/uriHelpers.spec.ts index 3e4e770d8..757285874 100644 --- a/src/services/common/uri/uriHelpers.spec.ts +++ b/src/services/common/uri/uriHelpers.spec.ts @@ -1,4 +1,24 @@ -import { stripFileUri, toFileUri } from './uriHelpers'; +import { stripFileUri, stripUriFragment, toFileUri } from './uriHelpers'; + +describe('stripUriFragment', () => { + test('when a uri has no fragment, then it is returned unchanged', () => { + expect(stripUriFragment('file:///var/mobile/DCIM/IMG_1234.JPG')).toBe('file:///var/mobile/DCIM/IMG_1234.JPG'); + }); + + test('when a uri has a binary-plist fragment from a spatial video, then the fragment is stripped', () => { + const fragmented = + 'file:///var/mobile/Media/DCIM/104APPLE/IMG_4854.MOV#YnBsaXN0MDDRAQJfEBtSZWNvbW1lbmRlZEZvckltbWVyc2l2ZU1vZGU'; + expect(stripUriFragment(fragmented)).toBe('file:///var/mobile/Media/DCIM/104APPLE/IMG_4854.MOV'); + }); + + test('when a ph:// uri is passed, then it is returned unchanged', () => { + expect(stripUriFragment('ph://ABCD-1234/L0/001')).toBe('ph://ABCD-1234/L0/001'); + }); + + test('when a content:// uri is passed, then it is returned unchanged', () => { + expect(stripUriFragment('content://media/external/images/1234')).toBe('content://media/external/images/1234'); + }); +}); describe('toFileUri', () => { test('when path has no prefix, then file:// is prepended', () => { diff --git a/src/services/common/uri/uriHelpers.ts b/src/services/common/uri/uriHelpers.ts index 25b633527..9aeab3335 100644 --- a/src/services/common/uri/uriHelpers.ts +++ b/src/services/common/uri/uriHelpers.ts @@ -1,5 +1,15 @@ export const FILE_URI_PREFIX = 'file://'; +/** + * Removes a URL fragment (everything from the first `#`), preserving the scheme and path. + * A literal `#` inside a path is always percent-encoded as `%23`, so any bare `#` is a + * fragment delimiter and safe to strip. + */ +export const stripUriFragment = (uri: string): string => { + const hashIndex = uri.indexOf('#'); + return hashIndex === -1 ? uri : uri.slice(0, hashIndex); +}; + /** * Converts a raw filesystem path to a file:// URI. * If the input already has any URI scheme (file://, ph://, content://, https://, …) diff --git a/src/services/photos/PhotoAssetFetchService.spec.ts b/src/services/photos/PhotoAssetFetchService.spec.ts index 0280cc08c..a73eac084 100644 --- a/src/services/photos/PhotoAssetFetchService.spec.ts +++ b/src/services/photos/PhotoAssetFetchService.spec.ts @@ -159,6 +159,49 @@ describe('fetchUri — cloud item', () => { }); }); +describe('fetchPlaybackUri — iOS local video', () => { + test('when a local video is requested on iOS, then ph:// is returned without calling getAssetInfo', async () => { + Object.defineProperty(Platform, 'OS', { get: () => 'ios', configurable: true }); + const item = makeLocalItem({ mediaType: 'video', uri: 'ph://ABCD-1234/L0/001' }); + + const result = await PhotoAssetFetchService.fetchPlaybackUri(item, makeSignal()); + + expect(result).toBe('ph://ABCD-1234/L0/001'); + expect(mockGetAssetInfo).not.toHaveBeenCalled(); + }); + + test('when a local video has no uri on iOS, then null is returned', async () => { + Object.defineProperty(Platform, 'OS', { get: () => 'ios', configurable: true }); + const item = makeLocalItem({ mediaType: 'video', uri: undefined }); + + const result = await PhotoAssetFetchService.fetchPlaybackUri(item, makeSignal()); + + expect(result).toBeNull(); + }); + + test('when a local photo is requested on iOS, then fetchUri is used and getAssetInfo is called', async () => { + Object.defineProperty(Platform, 'OS', { get: () => 'ios', configurable: true }); + mockGetAssetInfo.mockResolvedValue({ localUri: 'file:///var/mobile/IMG_1234.JPG', filename: 'IMG_1234.JPG' }); + const item = makeLocalItem({ mediaType: 'photo' }); + + const result = await PhotoAssetFetchService.fetchPlaybackUri(item, makeSignal()); + + expect(result).toBe('file:///var/mobile/IMG_1234.JPG'); + expect(mockGetAssetInfo).toHaveBeenCalledTimes(1); + }); + + test('when a local video is requested on Android, then fetchUri is used and getAssetInfo is called', async () => { + Object.defineProperty(Platform, 'OS', { get: () => 'android', configurable: true }); + mockGetAssetInfo.mockResolvedValue({ localUri: 'file:///sdcard/DCIM/video.mp4', filename: 'video.mp4' }); + const item = makeLocalItem({ mediaType: 'video', uri: 'content://media/video/123' }); + + const result = await PhotoAssetFetchService.fetchPlaybackUri(item, makeSignal()); + + expect(result).toBe('file:///sdcard/DCIM/video.mp4'); + expect(mockGetAssetInfo).toHaveBeenCalledTimes(1); + }); +}); + describe('resolveExportUri', () => { test('when item is local and platform is iOS, then the asset is copied to sandbox and a cleanup is returned', async () => { Object.defineProperty(Platform, 'OS', { get: () => 'ios', configurable: true }); diff --git a/src/services/photos/PhotoAssetFetchService.ts b/src/services/photos/PhotoAssetFetchService.ts index 9058693dc..114eebe34 100644 --- a/src/services/photos/PhotoAssetFetchService.ts +++ b/src/services/photos/PhotoAssetFetchService.ts @@ -151,6 +151,20 @@ export const PhotoAssetFetchService = { return downloadCloudAsset(item, signal); }, + /** + * Returns the URI to use for **playback** (react-native-video / image viewer). + * On iOS, local videos must use the canonical `ph://` URI so AVFoundation can load the + * asset via PHCachingImageManager.requestAVAsset. Direct `file:///var/mobile/…` paths into + * the Photos library fail with NSCocoaErrorDomain 257 (no permission). For every other + * combination (photos, Android, cloud) this delegates to `fetchUri` as usual. + */ + fetchPlaybackUri: async (item: PhotoItem | CloudPhotoItem, signal: AbortSignal): Promise => { + if (Platform.OS === 'ios' && item.type === 'local' && item.mediaType === 'video') { + return item.uri ?? null; + } + return PhotoAssetFetchService.fetchUri(item, signal); + }, + fetchLivePhotoComponents: async ( item: CloudPhotoItem, signal: AbortSignal, diff --git a/src/services/photos/PhotoMediaLibraryService.spec.ts b/src/services/photos/PhotoMediaLibraryService.spec.ts new file mode 100644 index 000000000..4dbfa3c39 --- /dev/null +++ b/src/services/photos/PhotoMediaLibraryService.spec.ts @@ -0,0 +1,65 @@ +import * as ExpoMediaLibrary from 'expo-media-library'; +import { photoMediaLibraryService } from './PhotoMediaLibraryService'; + +jest.mock('expo-media-library', () => ({ + getAssetInfoAsync: jest.fn(), + MediaType: { video: 'video', photo: 'photo' }, +})); + +const mockGetAssetInfoAsync = ExpoMediaLibrary.getAssetInfoAsync as jest.Mock; + +const makeAssetInfo = (overrides: Partial = {}): ExpoMediaLibrary.AssetInfo => + ({ + id: 'ABCD-1234/L0/001', + filename: 'IMG_1234.MOV', + mediaType: ExpoMediaLibrary.MediaType.video, + creationTime: 1_700_000_000_000, + modificationTime: 1_700_000_000_000, + duration: 5, + width: 1920, + height: 1080, + uri: 'ph://ABCD-1234/L0/001', + ...overrides, + }) as ExpoMediaLibrary.AssetInfo; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('getAssetInfo — localUri sanitization', () => { + test('when the asset has no fragment in its localUri, then localUri is returned as-is', async () => { + mockGetAssetInfoAsync.mockResolvedValue(makeAssetInfo({ localUri: 'file:///var/mobile/DCIM/IMG_1234.JPG' })); + + const result = await photoMediaLibraryService.getAssetInfo('ABCD-1234/L0/001'); + + expect(result.localUri).toBe('file:///var/mobile/DCIM/IMG_1234.JPG'); + }); + + test('when a video asset has a binary-plist fragment in its localUri, then the fragment is stripped', async () => { + const fragmentedUri = + 'file:///var/mobile/Media/DCIM/104APPLE/IMG_4854.MOV#YnBsaXN0MDDRAQJfEBtSZWNvbW1lbmRlZEZvckltbWVyc2l2ZU1vZGU'; + mockGetAssetInfoAsync.mockResolvedValue(makeAssetInfo({ localUri: fragmentedUri })); + + const result = await photoMediaLibraryService.getAssetInfo('ABCD-1234/L0/001'); + + expect(result.localUri).toBe('file:///var/mobile/Media/DCIM/104APPLE/IMG_4854.MOV'); + }); + + test('when the asset has no localUri, then the result is returned unchanged', async () => { + mockGetAssetInfoAsync.mockResolvedValue(makeAssetInfo({ localUri: undefined })); + + const result = await photoMediaLibraryService.getAssetInfo('ABCD-1234/L0/001'); + + expect(result.localUri).toBeUndefined(); + }); + + test('when the asset info contains other fields, then they are preserved alongside the sanitized localUri', async () => { + const fragmentedUri = 'file:///var/mobile/DCIM/IMG_4854.MOV#SomeFragment'; + mockGetAssetInfoAsync.mockResolvedValue(makeAssetInfo({ localUri: fragmentedUri, filename: 'IMG_4854.MOV' })); + + const result = await photoMediaLibraryService.getAssetInfo('ABCD-1234/L0/001'); + + expect(result.filename).toBe('IMG_4854.MOV'); + expect(result.localUri).toBe('file:///var/mobile/DCIM/IMG_4854.MOV'); + }); +}); diff --git a/src/services/photos/PhotoMediaLibraryService.ts b/src/services/photos/PhotoMediaLibraryService.ts index d5f2a1914..f0e3490c4 100644 --- a/src/services/photos/PhotoMediaLibraryService.ts +++ b/src/services/photos/PhotoMediaLibraryService.ts @@ -1,6 +1,14 @@ import { AssetInfo, MediaLibraryAssetInfoQueryOptions, getAssetInfoAsync } from 'expo-media-library'; +import { stripUriFragment } from 'src/services/common/uri/uriHelpers'; export const photoMediaLibraryService = { - getAssetInfo: (id: string, options?: MediaLibraryAssetInfoQueryOptions): Promise => - getAssetInfoAsync(id, options), + /** + * On iOS, `localUri` for video assets is `AVURLAsset.url.absoluteString`, which may carry a + * binary-plist URL fragment (spatial-video metadata). It is stripped here so all consumers + * receive a clean URI suitable for file I/O. + */ + getAssetInfo: async (id: string, options?: MediaLibraryAssetInfoQueryOptions): Promise => { + const info = await getAssetInfoAsync(id, options); + return info.localUri ? { ...info, localUri: stripUriFragment(info.localUri) } : info; + }, }; diff --git a/src/services/photos/PhotoUploadService.utils.ts b/src/services/photos/PhotoUploadService.utils.ts index 183f4af24..35d253078 100644 --- a/src/services/photos/PhotoUploadService.utils.ts +++ b/src/services/photos/PhotoUploadService.utils.ts @@ -1,17 +1,15 @@ +import { FILE_URI_PREFIX, stripUriFragment } from 'src/services/common/uri/uriHelpers'; + const ICLOUD_URI_SCHEME = 'ph://'; -const FILE_URI_SCHEME = 'file://'; const ANDROID_CONTENT_URI_SCHEME = 'content://'; const FALLBACK_EXTENSION = 'tmp'; export { ANDROID_CONTENT_URI_SCHEME, ICLOUD_URI_SCHEME }; -export const stripFileSchemeAndFragment = (uri: string): string => - decodeURIComponent( - uri.startsWith(FILE_URI_SCHEME) ? uri.slice(FILE_URI_SCHEME.length).split('#')[0] : uri.split('#')[0], - ); - export const stripFileScheme = (uri: string): string => - decodeURIComponent(uri.startsWith(FILE_URI_SCHEME) ? uri.slice(FILE_URI_SCHEME.length) : uri); + decodeURIComponent(uri.startsWith(FILE_URI_PREFIX) ? uri.slice(FILE_URI_PREFIX.length) : uri); + +export const stripFileSchemeAndFragment = (uri: string): string => stripFileScheme(stripUriFragment(uri)); export const extractExtensionFromContentUri = (uri: string): string => { const segment = uri.split('/').pop()?.split('?')[0] ?? '';