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
58 changes: 45 additions & 13 deletions patches/react-native-create-thumbnail+2.2.0.patch
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
diff --git a/node_modules/react-native-create-thumbnail/ios/CreateThumbnail.m b/node_modules/react-native-create-thumbnail/ios/CreateThumbnail.m
index 4c87cfa..26da63e 100644
index 4c87cfa..4764b8b 100644
--- a/node_modules/react-native-create-thumbnail/ios/CreateThumbnail.m
+++ b/node_modules/react-native-create-thumbnail/ios/CreateThumbnail.m
@@ -1,4 +1,5 @@
@@ -1,4 +1,6 @@
#import "CreateThumbnail.h"
+#import <ImageIO/ImageIO.h>

+#import <Photos/Photos.h>

@implementation CreateThumbnail

@@ -44,8 +45,10 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi

@@ -44,8 +46,13 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi
if ([url_ hasPrefix:@"http://"] || [url_ hasPrefix:@"https://"]) {
vidURL = [NSURL URLWithString:url];
+ } else if ([url_ hasPrefix:@"file://"]) {
+ vidURL = [NSURL URLWithString:url];
+ } else if ([url_ hasPrefix:@"ph://"]) {
+ // PHAsset URI — preserve as-is for AVURLAsset
+ vidURL = [NSURL URLWithString:url];
} else {
- // Consider it's file url path
+ // Consider it's a raw file path without scheme
vidURL = [NSURL fileURLWithPath:url];
}

@@ -79,7 +82,18 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi
@@ -79,9 +86,46 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi
reject(error.domain, error.description, nil);
};

- if ([url_ hasPrefix:@"http://"] || [url_ hasPrefix:@"https://"]) {
+ NSString *fileExt = [[url_ pathExtension] lowercaseString];
+ BOOL isImageFile = [fileExt isEqualToString:@"jpg"] ||
Expand All @@ -33,17 +37,45 @@ index 4c87cfa..26da63e 100644
+ [fileExt isEqualToString:@"heif"];
+
+ if (isImageFile) {
+ NSURL *imageURL = [url_ hasPrefix:@"file://"] ? [NSURL URLWithString:url] : [NSURL fileURLWithPath:url];
+ // file:// and ph:// are already valid NSURLs; anything else is a raw path
+ NSURL *imageURL = ([url_ hasPrefix:@"file://"] || [url_ hasPrefix:@"ph://"]) ? [NSURL URLWithString:url] : [NSURL fileURLWithPath:url];
+ CGFloat maxSize = MAX(maxWidth, maxHeight);
+ [self generateImageThumbnailAtURL:imageURL maxSize:maxSize completion:completionBlock failure:failBlock];
+ } else if ([url_ hasPrefix:@"http://"] || [url_ hasPrefix:@"https://"]) {
AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:vidURL options:@{@"AVURLAssetHTTPHeaderFieldsKey": headers}];
[self generateThumbImage:asset atTime:timeStamp maxWidth:maxWidth maxHeight:maxHeight timeToleranceMs:timeToleranceMs completion:completionBlock failure:failBlock];
+ } else if ([url_ hasPrefix:@"ph://"]) {
+ // ph:// URIs are Photos Library asset identifiers. Neither AVAsset nor AVURLAsset can
+ // resolve them — PHImageManager is the only API with the right authorization.
+ // localIdentifier is everything after "ph://" (e.g. "UUID/L0/001").
+ NSString *localIdentifier = [url substringFromIndex:5];
+ PHFetchResult<PHAsset *> *fetchResult = [PHAsset fetchAssetsWithLocalIdentifiers:@[localIdentifier] options:nil];
+ PHAsset *phAsset = fetchResult.firstObject;
+ if (!phAsset) {
+ NSError *fetchError = [NSError errorWithDomain:@"CreateThumbnail" code:3 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"PHAsset not found for identifier: %@", localIdentifier]}];
+ failBlock(fetchError);
+ return;
+ }
+ PHImageRequestOptions *requestOptions = [[PHImageRequestOptions alloc] init];
+ requestOptions.synchronous = NO;
+ requestOptions.deliveryMode = PHImageRequestOptionsDeliveryModeHighQualityFormat;
+ requestOptions.networkAccessAllowed = NO;
+ CGSize targetSize = CGSizeMake(maxWidth, maxHeight);
+ [[PHImageManager defaultManager] requestImageForAsset:phAsset targetSize:targetSize contentMode:PHImageContentModeAspectFit options:requestOptions resultHandler:^(UIImage *thumbnail, NSDictionary *info) {
+ if (thumbnail) {
+ completionBlock(thumbnail);
+ } else {
+ NSError *imgError = [NSError errorWithDomain:@"CreateThumbnail" code:4 userInfo:@{NSLocalizedDescriptionKey: @"PHImageManager returned nil image"}];
+ failBlock(imgError);
+ }
+ }];
} else {
@@ -120,6 +134,36 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi
AVAsset *asset = [AVAsset assetWithURL:vidURL];
[self generateLocalMediaThumbImage:asset atTime:timeStamp completion:completionBlock failure:failBlock];
@@ -120,6 +164,36 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi
return;
}

+- (void) generateImageThumbnailAtURL:(NSURL *)imageURL maxSize:(CGFloat)maxSize completion:(void (^)(UIImage* thumbnail))completion failure:(void (^)(NSError* error))failure {
+ @autoreleasepool {
+ CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)imageURL, NULL);
Expand Down Expand Up @@ -77,7 +109,7 @@ index 4c87cfa..26da63e 100644
- (void) generateThumbImage:(AVURLAsset *)asset atTime:(int)timeStamp maxWidth:(CGFloat)maxWidth maxHeight:(CGFloat)maxHeight timeToleranceMs:(int)timeToleranceMs completion:(void (^)(UIImage* thumbnail))completion failure:(void (^)(NSError* error))failure {
AVAssetImageGenerator *generator = [[AVAssetImageGenerator alloc] initWithAsset:asset];
generator.appliesPreferredTrackTransform = YES;
@@ -141,6 +185,7 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi
@@ -141,6 +215,7 @@ RCT_EXPORT_METHOD(create:(NSDictionary *)config findEventsWithResolver:(RCTPromi
- (void) generateLocalMediaThumbImage:(AVAsset *)asset atTime:(int)timeStamp completion:(void (^)(UIImage* thumbnail))completion failure:(void (^)(NSError* error))failure {
AVAssetImageGenerator *imageGenerator = [[AVAssetImageGenerator alloc] initWithAsset:asset];
imageGenerator.appliesPreferredTrackTransform = YES;
Expand Down
43 changes: 28 additions & 15 deletions src/screens/PhotosScreen/components/PhotoItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { LinearGradient } from 'expo-linear-gradient';
import { ArrowUpIcon, CheckIcon, CloudIcon, CloudSlashIcon, ImageIcon } from 'phosphor-react-native';
import { ArrowUpIcon, CheckIcon, CloudIcon, CloudSlashIcon, ImageIcon, PlayIcon } from 'phosphor-react-native';
import { memo, useCallback, useEffect, useRef } from 'react';
import { Animated, Easing, Image, StyleSheet, TouchableOpacity, View } from 'react-native';
import { Circle } from 'react-native-progress';
Expand Down Expand Up @@ -72,6 +72,30 @@ const SelectOverlay = ({
);
};

const VideoBadge = ({ duration }: { duration?: string }): JSX.Element => {
const tailwind = useTailwind();
const getColor = useGetColor();

return (
<View style={[tailwind('absolute justify-center items-end'), { bottom: 8, right: 8 }]} pointerEvents="none">
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.08)', 'rgba(0,0,0,0.32)', 'rgba(0,0,0,0.6)']}
locations={[0, 0.4, 0.7, 1]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.durationShadow}
/>
{duration ? (
<AppText medium style={[tailwind('text-sm'), { color: getColor('text-white') }]}>
{duration}
</AppText>
) : (
<PlayIcon size={12} weight="fill" color="#fff" />
)}
</View>
);
};

const localPhotoCellAreEqual = (prev: CellProps & { item: PhotoItemType }, next: CellProps & { item: PhotoItemType }) =>
prev.item.id === next.item.id &&
prev.item.backupState === next.item.backupState &&
Expand Down Expand Up @@ -122,20 +146,7 @@ const LocalPhotoCell = memo(
</View>
)}

{item.mediaType === 'video' && item.duration && (
<View style={[tailwind('absolute justify-center items-end'), { bottom: 8, right: 8 }]} pointerEvents="none">
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.08)', 'rgba(0,0,0,0.32)', 'rgba(0,0,0,0.6)']}
locations={[0, 0.4, 0.7, 1]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.durationShadow}
/>
<AppText medium style={[tailwind('text-sm'), { color: getColor('text-white') }]}>
{item.duration}
</AppText>
</View>
)}
{item.mediaType === 'video' && <VideoBadge duration={item.duration} />}

<SelectOverlay isSelectMode={isSelectMode} isSelected={isSelected} />
</TouchableOpacity>
Expand Down Expand Up @@ -176,6 +187,8 @@ const CloudPhotoCell = memo(
<CloudIcon size={14} color={getColor('text-white')} weight="fill" />
</View>

{item.mediaType === 'video' && <VideoBadge />}

<SelectOverlay isSelectMode={isSelectMode} isSelected={isSelected} />
</TouchableOpacity>
);
Expand Down
9 changes: 8 additions & 1 deletion src/services/common/uri/uriHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
export const FILE_URI_PREFIX = 'file://';

/**
* Converts a raw filesystem path to a file:// URI.
* If the input already has any URI scheme (file://, ph://, content://, https://, …)
* it is returned unchanged — callers can pass any path or URI safely.
*/
export const toFileUri = (path: string): string => {
if (path.startsWith(FILE_URI_PREFIX)) return path;
if (path.includes('://')) {
return path;
}
const absolutePath = path.startsWith('/') ? path : `/${path}`;
return `${FILE_URI_PREFIX}${encodeURI(decodeURIComponent(absolutePath))}`;
};
Expand Down
16 changes: 13 additions & 3 deletions src/services/photos/PhotoAssetFetchService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,31 @@ describe('fetchUri — cloud item', () => {
expect(result).toBeNull();
});

test('when asset is not cached and has a fileId, then downloadFile is called and the cache path is returned', async () => {
mockGetCloudAssetById.mockResolvedValue({ fileId: 'file-id-123', fileSize: 500_000 });
test('when asset is not cached and has a fileId, then downloadFile is called with the asset bucket and the cache path is returned', async () => {
mockGetCloudAssetById.mockResolvedValue({ fileId: 'file-id-123', fileSize: 500_000, bucket: 'photos-bucket' });
mockGetUser.mockResolvedValue({ bucket: 'drive-root-bucket', mnemonic: 'mnemonic' });
(fileSystemService.pathToUri as jest.Mock).mockReturnValue('file:///cache/photo_preview/cloud-uuid-1.jpg');

const result = await PhotoAssetFetchService.fetchUri(makeCloudItem(), makeSignal());

expect(mockDownloadFile).toHaveBeenCalledWith(
expect.anything(),
'bucket-id',
'photos-bucket',
'file-id-123',
expect.objectContaining({ downloadPath: '/cache/photo_preview/cloud-uuid-1.jpg' }),
500_000,
);
expect(result).toBe('file:///cache/photo_preview/cloud-uuid-1.jpg');
});

test('when asset is not cached but has no bucket, then null is returned without downloading', async () => {
mockGetCloudAssetById.mockResolvedValue({ fileId: 'file-id-123', fileSize: 500_000, bucket: null });

const result = await PhotoAssetFetchService.fetchUri(makeCloudItem(), makeSignal());

expect(mockDownloadFile).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});

describe('resolveExportUri', () => {
Expand Down
6 changes: 5 additions & 1 deletion src/services/photos/PhotoAssetFetchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const downloadCloudAsset = async (item: CloudPhotoItem, signal: AbortSignal): Pr
logger.warn(`[PhotoAssetFetchService] No fileId for cloud asset ${item.id}, skipping download`);
return null;
}
if (!asset.bucket) {
logger.warn(`[PhotoAssetFetchService] No bucket for cloud asset ${item.id}, skipping download`);
return null;
}

const user = await asyncStorageService.getUser();

Expand All @@ -51,7 +55,7 @@ const downloadCloudAsset = async (item: CloudPhotoItem, signal: AbortSignal): Pr
try {
await driveFileService.downloadFile(
user,
user.bucket,
asset.bucket,
asset.fileId,
{
downloadPath: cachePath,
Expand Down
30 changes: 25 additions & 5 deletions src/services/photos/PhotoCloudBrowser.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { driveFolderService } from 'src/services/drive/folder/driveFolder.service';
import { photosLocalDB } from './database/photosLocalDB';
import { photosDeviceService } from './photosDeviceService';
import { photoCloudBrowser } from './PhotoCloudBrowser';
import { photosDeviceService } from './photosDeviceService';

jest.mock('src/services/drive/folder/driveFolder.service', () => ({
driveFolderService: {
Expand All @@ -26,6 +26,8 @@ jest.mock('./database/photosLocalDB', () => ({
deleteCloudAsset: jest.fn(),
getCloudAssetMonthsByDevice: jest.fn(),
getSyncedMonths: jest.fn(),
getDistinctCloudAssetDeviceIds: jest.fn().mockResolvedValue([]),
deleteCloudAssetsByDevice: jest.fn(),
},
}));

Expand Down Expand Up @@ -78,10 +80,7 @@ describe('PhotoCloudBrowser.listDeviceFolders', () => {

const result = await photoCloudBrowser.listDeviceFolders();

expect(result).toEqual([
{ uuid: 'd1-uuid' },
{ uuid: 'd2-uuid' },
]);
expect(result).toEqual([{ uuid: 'd1-uuid' }, { uuid: 'd2-uuid' }]);
});

test('when the device service returns a deleted device, then it is excluded from the list', async () => {
Expand Down Expand Up @@ -564,4 +563,25 @@ describe('PhotoCloudBrowser.syncAllHistory', () => {

expect(onMonthFetched).toHaveBeenCalledTimes(1);
});

test('when local DB has a device ID not returned by the backend, then its cloud assets are deleted', async () => {
mockDeviceService.listDevices.mockResolvedValue([makeDevice('active-uuid', 'iPhone')]);
mockPhotosLocalDB.getDistinctCloudAssetDeviceIds.mockResolvedValue(['active-uuid', 'orphan-uuid']);
mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(Infinity);

await photoCloudBrowser.syncAllHistory({});

expect(mockPhotosLocalDB.deleteCloudAssetsByDevice).toHaveBeenCalledWith('orphan-uuid');
expect(mockPhotosLocalDB.deleteCloudAssetsByDevice).not.toHaveBeenCalledWith('active-uuid');
});

test('when all local device IDs match backend devices, then no cloud assets are deleted', async () => {
mockDeviceService.listDevices.mockResolvedValue([makeDevice('active-uuid', 'iPhone')]);
mockPhotosLocalDB.getDistinctCloudAssetDeviceIds.mockResolvedValue(['active-uuid']);
mockPhotosLocalDB.getCloudFetchCacheAge.mockResolvedValue(Infinity);

await photoCloudBrowser.syncAllHistory({});

expect(mockPhotosLocalDB.deleteCloudAssetsByDevice).not.toHaveBeenCalled();
});
});
40 changes: 37 additions & 3 deletions src/services/photos/PhotoCloudBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ class PhotoCloudBrowserService {

async listDeviceFolders(): Promise<{ uuid: string }[]> {
const devices = await photosDeviceService.listDevices();
return devices
.filter((device) => device.status === 'EXISTS')
.map((device) => ({ uuid: device.uuid }));
return devices.filter((device) => device.status === 'EXISTS').map((device) => ({ uuid: device.uuid }));
}

async fetchMonth(params: {
Expand Down Expand Up @@ -70,11 +68,17 @@ class PhotoCloudBrowserService {
currentDeviceId?: string;
}): Promise<void> {
const { onMonthFetched, isCancelled, force, currentDeviceId } = options;
logger.info(
`[CloudBrowser] syncAllHistory — currentDeviceId=${currentDeviceId ?? 'none'}, force=${force ?? false}`,
);
const devices = await this.listDeviceFolders();
if (devices.length === 0) {
logger.info('[CloudBrowser] No device folders found — skipping sync');
return;
}
logger.info(`[CloudBrowser] Syncing ${devices.length} device(s): ${devices.map((d) => d.uuid).join(', ')}`);

await this.purgeDeletedDevices(devices, onMonthFetched);

const months = await this.discoverAvailableMonths(devices);

Expand Down Expand Up @@ -189,7 +193,13 @@ class PhotoCloudBrowserService {
currentDeviceId?: string;
}): Promise<void> {
const { deviceId, year, month, foundIds, currentDeviceId } = params;
logger.info(
`[CloudBrowser] reconcileCloudDeletions — device=${deviceId} ${year}/${String(month).padStart(2, '0')}, foundIds=${[...foundIds].length}, currentDeviceId=${currentDeviceId ?? 'none'}`,
);
const knownFromCloud = await this.localDB.getCloudAssetRemoteIdsByDeviceAndMonth(deviceId, year, month);
logger.info(
`[CloudBrowser] reconcileCloudDeletions — knownFromCloud=${knownFromCloud.size} in local DB for device=${deviceId} ${year}/${String(month).padStart(2, '0')}`,
);
const knownIds = new Set(knownFromCloud);

if (currentDeviceId && deviceId === currentDeviceId) {
Expand All @@ -212,17 +222,41 @@ class PhotoCloudBrowserService {
}
}

private async purgeDeletedDevices(activeDevices: { uuid: string }[], onPurged?: () => void): Promise<void> {
const activeDevicesIds = new Set(activeDevices.map((device) => device.uuid));
const localIds = await this.localDB.getDistinctCloudAssetDeviceIds();
const orphanedDeviceIds = localIds.filter((id) => !activeDevicesIds.has(id));
if (orphanedDeviceIds.length === 0) {
return;
}

logger.info(
`[CloudBrowser] Purging ${orphanedDeviceIds.length} deleted device(s) from local DB: ${orphanedDeviceIds.join(', ')}`,
);
for (const deviceId of orphanedDeviceIds) {
await this.localDB.deleteCloudAssetsByDevice(deviceId);
logger.info(`[CloudBrowser] Purged all cloud_asset rows for deleted device=${deviceId}`);
}
onPurged?.();
}

private async reconcileDeletedMonths(params: {
devices: { uuid: string }[];
discoveredMonths: { deviceId: string; year: number; month: number; monthFolderUuid: string }[];
currentDeviceId?: string;
}): Promise<void> {
const { devices, discoveredMonths, currentDeviceId } = params;
const discoveredSet = new Set(discoveredMonths.map((m) => `${m.deviceId}:${m.year}:${m.month}`));
logger.info(
`[CloudBrowser] reconcileDeletedMonths — ${devices.length} device(s) to reconcile: ${devices.map((d) => d.uuid).join(', ')}`,
);

for (const device of devices) {
const deviceId = device.uuid;
const cloudMonths = await this.localDB.getCloudAssetMonthsByDevice(deviceId);
logger.info(
`[CloudBrowser] reconcileDeletedMonths — device=${deviceId}: ${cloudMonths.length} month(s) in local DB: ${JSON.stringify(cloudMonths)}`,
);
const monthSet = new Set(cloudMonths.map((m) => `${m.year}:${m.month}`));

if (currentDeviceId && deviceId === currentDeviceId) {
Expand Down
Loading
Loading