Skip to content
Open
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
103 changes: 103 additions & 0 deletions src/screens/PhotoPreviewScreen/hooks/usePreviewSource.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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');
});
});
2 changes: 1 addition & 1 deletion src/screens/PhotoPreviewScreen/hooks/usePreviewSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
22 changes: 21 additions & 1 deletion src/services/common/uri/uriHelpers.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down
10 changes: 10 additions & 0 deletions src/services/common/uri/uriHelpers.ts
Original file line number Diff line number Diff line change
@@ -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://, …)
Expand Down
43 changes: 43 additions & 0 deletions src/services/photos/PhotoAssetFetchService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
14 changes: 14 additions & 0 deletions src/services/photos/PhotoAssetFetchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> => {
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,
Expand Down
65 changes: 65 additions & 0 deletions src/services/photos/PhotoMediaLibraryService.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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');
});
});
12 changes: 10 additions & 2 deletions src/services/photos/PhotoMediaLibraryService.ts
Original file line number Diff line number Diff line change
@@ -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<AssetInfo> =>
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<AssetInfo> => {
const info = await getAssetInfoAsync(id, options);
return info.localUri ? { ...info, localUri: stripUriFragment(info.localUri) } : info;
},
};
12 changes: 5 additions & 7 deletions src/services/photos/PhotoUploadService.utils.ts
Original file line number Diff line number Diff line change
@@ -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] ?? '';
Expand Down
Loading