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
2 changes: 2 additions & 0 deletions assets/lang/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ const translations = {
storageFull: 'Storage is full',
backupCompleted: 'Backup completed',
gettingPhotos: 'Getting photos from the cloud',
scanningGallery: 'Scanning gallery',
},
photosLocked: {
title: 'Photos is locked',
Expand Down Expand Up @@ -1100,6 +1101,7 @@ const translations = {
storageFull: 'Almacenamiento lleno',
backupCompleted: 'Backup completado',
gettingPhotos: 'Obteniendo fotos de la nube',
scanningGallery: 'Escaneando galería',
},
photosLocked: {
title: 'Photos está bloqueado',
Expand Down
178 changes: 89 additions & 89 deletions ios/Internxt.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/navigation/TabExplorerNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AppState, AppStateStatus, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import SecurityModal from 'src/components/modals/SecurityModal';
import { authThunks } from 'src/store/slices/auth';
import { checkPermissionRevocationThunk } from 'src/store/slices/photos';
import { runBackupCycleThunk } from 'src/store/slices/photos';
import { storageThunks } from 'src/store/slices/storage';
import { useTailwind } from 'tailwind-rn';
import BottomTabNavigator from '../components/BottomTabNavigator';
Expand Down Expand Up @@ -56,7 +56,7 @@ export default function TabExplorerNavigator(props: RootStackScreenProps<'TabExp

async function handleOnAppStateChange(state: AppStateStatus) {
if (state === 'active') {
dispatch(checkPermissionRevocationThunk());
dispatch(runBackupCycleThunk());
try {
await dispatch(storageThunks.loadLimitThunk()).unwrap();
} catch {
Expand Down
2 changes: 1 addition & 1 deletion src/screens/HomeScreen/useDiscoverPhotosSheet.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const makeWrapper = (photosState?: Partial<PhotosState>) => {
const store = configureStore({
reducer: { photos: photosReducer },
preloadedState: {
photos: { enabled: false, networkCondition: 'wifi-only', permissionStatus: 'undetermined', ...photosState },
photos: { enabled: false, networkCondition: 'wifi-only', permissionStatus: 'undetermined', syncStatus: 'idle', pendingCount: 0, totalScannedCount: 0, deviceId: null, ...photosState },
},
});
return ({ children }: { children: React.ReactNode }) => <Provider store={store}>{children}</Provider>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { CloudArrowDownIcon, CheckCircleIcon, PauseCircleIcon, PlayCircleIcon, WarningCircleIcon } from 'phosphor-react-native';
import {
CheckCircleIcon,
CloudArrowDownIcon,
DeviceMobileIcon,
PauseCircleIcon,
PlayCircleIcon,
WarningCircleIcon,
} from 'phosphor-react-native';
import { ActivityIndicator } from 'react-native';
import AppText from 'src/components/AppText';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../../../assets/lang/strings';

interface ColorProps {
color: string;
}

interface CountProps {
count: number;
color: string;
Expand All @@ -18,11 +29,20 @@ export const GroupHeaderCount = ({ count, color }: CountProps): JSX.Element => {
);
};

interface FetchingProps {
color: string;
}
export const GroupHeaderScanning = ({ color }: ColorProps): JSX.Element => {
const tailwind = useTailwind();
return (
<>
<ActivityIndicator size="small" color={color} />
<AppText medium style={[tailwind('text-base'), { color }]}>
{strings.screens.photos.groupHeader.scanningGallery}
</AppText>
<DeviceMobileIcon size={24} color={color} weight="regular" />
</>
);
};

export const GroupHeaderFetching = ({ color }: FetchingProps): JSX.Element => {
export const GroupHeaderFetching = ({ color }: ColorProps): JSX.Element => {
const tailwind = useTailwind();
return (
<>
Expand Down Expand Up @@ -100,11 +120,7 @@ export const GroupHeaderPausedStorageFull = ({ dangerColor }: PausedStorageFullP
);
};

interface CompletedProps {
color: string;
}

export const GroupHeaderCompleted = ({ color }: CompletedProps): JSX.Element => {
export const GroupHeaderCompleted = ({ color }: ColorProps): JSX.Element => {
const tailwind = useTailwind();
return (
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import {
GroupHeaderFetching,
GroupHeaderPaused,
GroupHeaderPausedStorageFull,
GroupHeaderScanning,
GroupHeaderUploading,
} from './GroupHeaderStatus';

export type GroupSyncStatus =
| { type: 'count'; count: number }
| { type: 'scanning' }
| { type: 'fetching' }
| { type: 'uploading'; count?: number; backupProgress?: number }
| { type: 'paused'; count: number }
Expand Down Expand Up @@ -74,6 +76,7 @@ const PhotosGroupHeader = memo(

<View style={[tailwind('flex-row items-center overflow-hidden'), { maxWidth: 226, gap: 8 }]}>
{syncStatus.type === 'count' && <GroupHeaderCount count={syncStatus.count} color={statusColor} />}
{syncStatus.type === 'scanning' && <GroupHeaderScanning color={statusColor} />}
{syncStatus.type === 'fetching' && <GroupHeaderFetching color={statusColor} />}
{syncStatus.type === 'uploading' && (
<GroupHeaderUploading
Expand Down
20 changes: 17 additions & 3 deletions src/screens/PhotosScreen/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { View } from 'react-native';
import AppScreen from 'src/components/AppScreen';
import { useAppDispatch, useAppSelector } from 'src/store/hooks';
import { photosActions } from 'src/store/slices/photos';
import { photosActions, PhotoSyncStatus } from 'src/store/slices/photos';
import { useTailwind } from 'tailwind-rn';
import { photoPermissionService } from '../../services/photos/photoPermissionService';
import BackupDisabledBanner from './components/BackupDisabledBanner';
Expand All @@ -15,6 +15,7 @@ import { MOCK_GROUP, MOCK_GROUP_BACKING_UP, MOCK_MULTI_DATE_GROUPS } from './moc
import { PhotosAccessState, PhotosSyncStatus } from './types';

type ScreenVariant =
| 'scanning'
| 'fetching'
| 'uploading'
| 'paused'
Expand All @@ -38,6 +39,12 @@ const multiDateGroups = ({ first }: { first: GroupSyncStatus }): TimelineDateGro

const getScreenConfig = (variant: ScreenVariant): ScreenConfig => {
switch (variant) {
case 'scanning':
return {
syncStatus: { type: 'fetching' },
accessState: { type: 'available' },
groups: [{ group: MOCK_GROUP, syncStatus: { type: 'scanning' } }],
};
case 'fetching':
return {
syncStatus: { type: 'fetching' },
Expand Down Expand Up @@ -89,10 +96,17 @@ const getScreenConfig = (variant: ScreenVariant): ScreenConfig => {
}
};

const variantFromSyncStatus: Record<PhotoSyncStatus, ScreenVariant> = {
scanning: 'scanning',
idle: 'synced',
synced: 'synced',
error: 'synced',
};

const PhotosScreen = (): JSX.Element => {
const tailwind = useTailwind();
const dispatch = useAppDispatch();
const { enabled, permissionStatus } = useAppSelector((state) => state.photos);
const { enabled, permissionStatus, syncStatus } = useAppSelector((state) => state.photos);
const [isSheetOpen, setIsSheetOpen] = useState(false);

useEffect(() => {
Expand All @@ -106,7 +120,7 @@ const PhotosScreen = (): JSX.Element => {
checkPermissionStatus();
}, [permissionStatus]);

const variant: ScreenVariant = enabled ? 'synced' : 'backup-off';
const variant: ScreenVariant = enabled ? variantFromSyncStatus[syncStatus] : 'backup-off';
const { accessState, groups } = useMemo(() => getScreenConfig(variant), [variant]);

const handleEnableBackup = useCallback(() => setIsSheetOpen(true), []);
Expand Down
23 changes: 23 additions & 0 deletions src/services/photos/PhotoAssetScanner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as MediaLibrary from 'expo-media-library';
import { logger } from 'src/services/common';

export const PhotoAssetScanner = {
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);

logger.info(`[PhotoAssetScanner] Scan complete — ${results.length} assets`);
return results;
},
};
47 changes: 47 additions & 0 deletions src/services/photos/PhotoDeduplicator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { PhotoDeduplicator } from './PhotoDeduplicator';
import { photosLocalDB } from './database/photosLocalDB';

jest.mock('./database/photosLocalDB', () => ({
photosLocalDB: { getSyncedIds: jest.fn() },
}));

const mockDB = photosLocalDB as jest.Mocked<typeof photosLocalDB>;

const makeAssets = (ids: string[]) => ids.map((id) => ({ id })) as never;

describe('PhotoDeduplicator', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('when DB is empty, then all assets are returned as pending', async () => {
mockDB.getSyncedIds.mockResolvedValueOnce(new Set());

const result = await PhotoDeduplicator.filter(makeAssets(['a', 'b', 'c']));

expect(result.map((a) => a.id)).toEqual(['a', 'b', 'c']);
});

test('when all assets are synced, then empty array is returned', async () => {
mockDB.getSyncedIds.mockResolvedValueOnce(new Set(['a', 'b', 'c']));

const result = await PhotoDeduplicator.filter(makeAssets(['a', 'b', 'c']));

expect(result).toHaveLength(0);
});

test('when some assets are synced, then only pending ones are returned', async () => {
mockDB.getSyncedIds.mockResolvedValueOnce(new Set(['b']));

const result = await PhotoDeduplicator.filter(makeAssets(['a', 'b', 'c']));

expect(result.map((a) => a.id)).toEqual(['a', 'c']);
});

test('when input is empty, then no DB call is made and empty array is returned', async () => {
const result = await PhotoDeduplicator.filter([]);

expect(mockDB.getSyncedIds).not.toHaveBeenCalled();
expect(result).toHaveLength(0);
});
});
10 changes: 10 additions & 0 deletions src/services/photos/PhotoDeduplicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as MediaLibrary from 'expo-media-library';
import { photosLocalDB } from './database/photosLocalDB';

export const PhotoDeduplicator = {
async filter(assets: MediaLibrary.Asset[]): Promise<MediaLibrary.Asset[]> {
if (assets.length === 0) return [];
const syncedIds = await photosLocalDB.getSyncedIds(assets.map((asset) => asset.id));
return assets.filter((asset) => !syncedIds.has(asset.id));
},
};
14 changes: 14 additions & 0 deletions src/services/photos/PhotoDeviceId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import uuid from 'react-native-uuid';
import asyncStorageService from 'src/services/AsyncStorageService';
import { AsyncStorageKey } from 'src/types';

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

const id = uuid.v4() as string;
await asyncStorageService.saveItem(AsyncStorageKey.PhotosDeviceId, id);
return id;
},
};
Loading
Loading