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
31 changes: 26 additions & 5 deletions src/network/NetworkFacade.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as RNFS from '@dr.pogodin/react-native-fs';
import { AbortError } from './errors';
import { logger } from '@internxt-mobile/services/common';
import { decryptFile, encryptFile, encryptFileToChunks, joinFiles } from '@internxt/rn-crypto';
import { ALGORITHMS, Network } from '@internxt/sdk/dist/network';
Expand All @@ -12,18 +11,24 @@ import { Platform } from 'react-native';
import ReactNativeBlobUtil from 'react-native-blob-util';
import { randomBytes } from 'react-native-crypto';
import uuid from 'react-native-uuid';
import { AbortError } from './errors';

import drive from '@internxt-mobile/services/drive';
import pLimit, { LimitFunction } from 'p-limit';
import { ripemd160 } from '../@inxt-js/lib/crypto';
import { generateFileKey } from './crypto';
import appService from '../services/AppService';
import { driveEvents } from '../services/drive/events';
import fileSystemService from '../services/FileSystemService';
import { Abortable } from '../types';
import { generateFileKey } from './crypto';
import { EncryptedFileDownloadedParams } from './download';
import { getAuthFromCredentials, NetworkCredentials } from './requests';

const getCaseInsensitiveHeader = (headers: Record<string, string>, name: string): string | undefined => {
const key = Object.keys(headers).find((key) => key.toLowerCase() === name.toLowerCase());
return key ? headers[key] : undefined;
};

interface UploadMultipartOptions {
partSize: number;
uploadingCallback?: (progress: number) => void;
Expand Down Expand Up @@ -301,12 +306,28 @@ export class NetworkFacade {
): Promise<void> {
const uploadWithRetry = async (path: string, url: string, index: number): Promise<PartInfo> => {
try {
return await this.uploadPart(url, path, index, uploadState.partsUploadedBytes, fileSize, options.uploadingCallback, abortCtx);
return await this.uploadPart(
url,
path,
index,
uploadState.partsUploadedBytes,
fileSize,
options.uploadingCallback,
abortCtx,
);
} catch (error) {
if ((error as Error)?.name === AbortError.errorName) throw error;
logger.error(`First attempt failed for part ${index + 1}, retrying...`);
try {
return await this.uploadPart(url, path, index, uploadState.partsUploadedBytes, fileSize, options.uploadingCallback, abortCtx);
return await this.uploadPart(
url,
path,
index,
uploadState.partsUploadedBytes,
fileSize,
options.uploadingCallback,
abortCtx,
);
} catch (retryError) {
logger.error(`Retry failed for part ${index + 1}`);
throw retryError;
Expand Down Expand Up @@ -348,7 +369,7 @@ export class NetworkFacade {
abortCtx?.activeFetchTasks.add(fetchTask);
try {
const response = await fetchTask;
const etag = Platform.OS === 'android' ? response.info().headers.ETag : response.info().headers.Etag;
const etag = getCaseInsensitiveHeader(response.info().headers, 'etag');
if (!etag) throw new Error('Missing ETag in upload response');

return {
Expand Down
5 changes: 5 additions & 0 deletions src/services/common/network/upload/upload.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CreateThumbnailEntryPayload,
DriveFileData,
FileEntryByUuid,
ReplaceFile,
Thumbnail,
} from '@internxt/sdk/dist/drive/storage/types';
import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
Expand Down Expand Up @@ -67,6 +68,10 @@ class UploadService {
return this.sdk.storageV2.createFileEntryByUuid(entry);
}

public async replaceFileEntry(fileUuid: string, payload: ReplaceFile): Promise<DriveFileData> {
return this.sdk.storageV2.replaceFile(fileUuid, payload);
}

public async createThumbnailEntry(entry: CreateThumbnailEntryPayload): Promise<Thumbnail> {
return this.sdk.storageV2.createThumbnailEntryWithUUID(entry);
}
Expand Down
46 changes: 34 additions & 12 deletions src/services/photos/PhotoAssetScanner.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,43 @@
import * as MediaLibrary from 'expo-media-library';
import { logger } from 'src/services/common';

const PAGE_SIZE = 200;
const MEDIA_TYPES = [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video];

const paginateAssets = async (
options: Omit<MediaLibrary.AssetsOptions, 'first' | 'after'>,
onAssetFetched: (asset: MediaLibrary.Asset) => boolean,
): Promise<void> => {
let cursor: string | undefined;
do {
const page = await MediaLibrary.getAssetsAsync({ first: PAGE_SIZE, after: cursor, ...options });
for (const asset of page.assets) {
if (onAssetFetched(asset)) return;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s an early return. Initially, the scanAll and getAssetsByIds functions were separate, when asset retrieval was consolidated using the MediaLibrary library, this was added to stop the process. Perhaps I should separate them again, as having onAssetFetched return a boolean to indicate whether to stop paginating assets or not is a bit confusing

}
cursor = page.hasNextPage ? page.endCursor : undefined;
} while (cursor);
};

export const PhotoAssetScanner = {
async getAssetsByIds(assetIds: string[]): Promise<MediaLibrary.Asset[]> {
const pendingIdSet = new Set(assetIds);
const resolvedAssets: MediaLibrary.Asset[] = [];

await paginateAssets({ mediaType: MEDIA_TYPES }, (asset) => {
if (pendingIdSet.has(asset.id)) resolvedAssets.push(asset);
return resolvedAssets.length === assetIds.length;
});

return resolvedAssets;
},

async scanAll(): Promise<MediaLibrary.Asset[]> {
const results: MediaLibrary.Asset[] = [];
let cursor: string | undefined;

do {
const batch = await MediaLibrary.getAssetsAsync({
first: 200,
after: cursor,
mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
sortBy: MediaLibrary.SortBy.modificationTime,
});
results.push(...batch.assets);
cursor = batch.hasNextPage ? batch.endCursor : undefined;
} while (cursor);

await paginateAssets({ mediaType: MEDIA_TYPES, sortBy: MediaLibrary.SortBy.modificationTime }, (asset) => {
results.push(asset);
return false;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So that it never stops fetching assets, as we want to fetch them all. In any case, as I mentioned in my previous comment, perhaps the best approach is to refactor the common funciton so that the only thing the functions scanAll and getAssetsByIds have in common is that they use MediaLibrary.getAssetsAsync

});

logger.info(`[PhotoAssetScanner] Scan complete — ${results.length} assets`);
return results;
Expand Down
78 changes: 78 additions & 0 deletions src/services/photos/PhotoBackupFolders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import asyncStorageService from 'src/services/AsyncStorageService';
import { logger } from 'src/services/common';
import { createFolderWithMerge } from 'src/services/drive/folder/folderOrchestration.service';

const PHOTOS_BACKUP_ROOT_NAME = 'Photos Backup';

class PhotoBackupFolderService {
private photosRootUuid: string | null = null;
private deviceFolderUuid = new Map<string, string>();

Check warning on line 9 in src/services/photos/PhotoBackupFolders.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'deviceFolderUuid' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ3d7ksmqczrkV5U2A50&open=AZ3d7ksmqczrkV5U2A50&pullRequest=436
private yearFolderUuid = new Map<string, string>();

Check warning on line 10 in src/services/photos/PhotoBackupFolders.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'yearFolderUuid' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ3d7ksmqczrkV5U2A51&open=AZ3d7ksmqczrkV5U2A51&pullRequest=436
private monthFolderUuid = new Map<string, string>();

Check warning on line 11 in src/services/photos/PhotoBackupFolders.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'monthFolderUuid' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ3d7ksmqczrkV5U2A52&open=AZ3d7ksmqczrkV5U2A52&pullRequest=436
private dayFolderUuid = new Map<string, string>();

Check warning on line 12 in src/services/photos/PhotoBackupFolders.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'dayFolderUuid' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-mobile&issues=AZ3d7ksmqczrkV5U2A53&open=AZ3d7ksmqczrkV5U2A53&pullRequest=436

async getOrCreateFolderForDate(deviceId: string, date: Date): Promise<string> {
const year = date.getFullYear().toString();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');

const dayKey = `${deviceId}/${year}/${month}/${day}`;
const localDayFolderUuid = this.dayFolderUuid.get(dayKey);
if (localDayFolderUuid) return localDayFolderUuid;

const photosRootUuid = await this.getOrCreatePhotosRoot();
const deviceUuid = await this.getOrCreateDeviceFolder(deviceId, photosRootUuid);
const yearUuid = await this.getOrCreateYearFolder(deviceId, year, deviceUuid);
const monthUuid = await this.getOrCreateMonthFolder(deviceId, year, month, yearUuid);

logger.info(`[PhotoBackupFolders] Creating day folder ${dayKey}`);
const dayUuid = await createFolderWithMerge(monthUuid, day);
this.dayFolderUuid.set(dayKey, dayUuid);
return dayUuid;
}

clearCache(): void {
this.photosRootUuid = null;
this.deviceFolderUuid.clear();
this.yearFolderUuid.clear();
this.monthFolderUuid.clear();
this.dayFolderUuid.clear();
}

private async getOrCreatePhotosRoot(): Promise<string> {
if (this.photosRootUuid) return this.photosRootUuid;

const user = await asyncStorageService.getUser();
this.photosRootUuid = await createFolderWithMerge(user.rootFolderId, PHOTOS_BACKUP_ROOT_NAME);
return this.photosRootUuid;
}

private async getOrCreateFolder(
cache: Map<string, string>,
key: string,
parentUuid: string,
name: string,
): Promise<string> {
const cached = cache.get(key);
if (cached) {
return cached;
}
const uuid = await createFolderWithMerge(parentUuid, name);
cache.set(key, uuid);
return uuid;
}

private getOrCreateDeviceFolder(deviceId: string, photosRootUuid: string): Promise<string> {
return this.getOrCreateFolder(this.deviceFolderUuid, deviceId, photosRootUuid, deviceId);
}

private getOrCreateYearFolder(deviceId: string, year: string, deviceUuid: string): Promise<string> {
return this.getOrCreateFolder(this.yearFolderUuid, `${deviceId}/${year}`, deviceUuid, year);
}

private getOrCreateMonthFolder(deviceId: string, year: string, month: string, yearUuid: string): Promise<string> {
return this.getOrCreateFolder(this.monthFolderUuid, `${deviceId}/${year}/${month}`, yearUuid, month);
}
}

export const photoBackupFolders = new PhotoBackupFolderService();
32 changes: 22 additions & 10 deletions src/services/photos/PhotoDeduplicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,37 @@ import * as MediaLibrary from 'expo-media-library';
import { photosLocalDB } from './database/photosLocalDB';

const hasBeenEdited = (asset: MediaLibrary.Asset, syncedModificationTime: number | null): boolean => {
if (syncedModificationTime !== null && asset.modificationTime > syncedModificationTime) {
return true;
if (syncedModificationTime === null) {
return false;
}
return false;
const isEditedAsset = asset.modificationTime > syncedModificationTime;
return isEditedAsset;
};

export interface AssetsToSync {
newAssets: MediaLibrary.Asset[];
editedAssets: MediaLibrary.Asset[];
}

export const PhotoDeduplicator = {
async getAssetsToSync(assets: MediaLibrary.Asset[]): Promise<MediaLibrary.Asset[]> {
if (assets.length === 0) return [];
async getAssetsToSync(assets: MediaLibrary.Asset[]): Promise<AssetsToSync> {
if (assets.length === 0) {
return { newAssets: [], editedAssets: [] };
}

const syncedEntries = await photosLocalDB.getSyncedEntries(assets.map((asset) => asset.id));
const newAssets: MediaLibrary.Asset[] = [];
const editedAssets: MediaLibrary.Asset[] = [];

const uniqueAssets = assets.filter((asset) => {
for (const asset of assets) {
const syncedInfo = syncedEntries.get(asset.id);
if (!syncedInfo) {
return true;
newAssets.push(asset);
} else if (hasBeenEdited(asset, syncedInfo.modificationTime)) {
editedAssets.push(asset);
}
return hasBeenEdited(asset, syncedInfo.modificationTime);
});
}

return uniqueAssets;
return { newAssets, editedAssets };
},
};
6 changes: 3 additions & 3 deletions src/services/photos/PhotoDeviceId.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import uuid from 'react-native-uuid';
import asyncStorageService from 'src/services/AsyncStorageService';
import secureStorageService from 'src/services/SecureStorageService';
import { AsyncStorageKey } from 'src/types';

export const PhotoDeviceId = {
async getOrCreate(): Promise<string> {
const existing = await asyncStorageService.getItem(AsyncStorageKey.PhotosDeviceId);
const existing = await secureStorageService.getItem(AsyncStorageKey.PhotosDeviceId);
if (existing) return existing;

const id = uuid.v4() as string;
await asyncStorageService.saveItem(AsyncStorageKey.PhotosDeviceId, id);
await secureStorageService.setItem(AsyncStorageKey.PhotosDeviceId, id);
return id;
},
};
48 changes: 48 additions & 0 deletions src/services/photos/PhotoUploadQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as MediaLibrary from 'expo-media-library';
import pLimit from 'p-limit';
import { PhotoUploadService } from './PhotoUploadService';

const UPLOAD_CONCURRENCY = 3;

export interface AssetUploadJob {
asset: MediaLibrary.Asset;
existingRemoteFileId?: string;
}

interface UploadQueueCallbacks {
onAssetStart?: (assetId: string) => void;
onAssetProgress?: (assetId: string, ratio: number) => void;
onAssetDone?: (assetId: string, remoteFileId: string, modificationTime: number) => void;
onAssetError?: (assetId: string, error: Error) => void;
}

export const PhotoUploadQueue = {
async start(
jobs: AssetUploadJob[],
deviceId: string,
callbacks: UploadQueueCallbacks,
): Promise<void> {
const limit = pLimit(UPLOAD_CONCURRENCY);

await Promise.all(
jobs.map((job) =>
limit(async () => {
const { asset, existingRemoteFileId } = job;
callbacks.onAssetStart?.(asset.id);
try {
const remoteFileId = existingRemoteFileId
? await PhotoUploadService.replace(asset, existingRemoteFileId, deviceId, (ratio) =>
callbacks.onAssetProgress?.(asset.id, ratio),
)
: await PhotoUploadService.upload(asset, deviceId, (ratio) =>
callbacks.onAssetProgress?.(asset.id, ratio),
);
callbacks.onAssetDone?.(asset.id, remoteFileId, asset.modificationTime);
} catch (uploadError) {
callbacks.onAssetError?.(asset.id, uploadError as Error);
}
}),
),
);
},
};
Loading
Loading