diff --git a/__tests__/rntl/screens/PacksScreen.test.tsx b/__tests__/rntl/screens/PacksScreen.test.tsx index 63b73ff29..f50e8238f 100644 --- a/__tests__/rntl/screens/PacksScreen.test.tsx +++ b/__tests__/rntl/screens/PacksScreen.test.tsx @@ -149,6 +149,10 @@ const latestModelSource = { format: 'onnx' as const, }; +const withProgress = expect.objectContaining({ onProgress: expect.any(Function) }); + +type ReportProgress = (bytesWritten: number, contentLength: number) => void; + describe('PacksScreen', () => { beforeEach(() => { jest.clearAllMocks(); @@ -364,8 +368,8 @@ describe('PacksScreen', () => { await waitFor(() => expect(getByText('Update available')).toBeTruthy()); fireEvent.press(getByTestId('update-pack-button')); - await waitFor(() => expect(mockAcquireLatestPack).toHaveBeenCalledWith('example-project', {}, readyModel)); - expect(mockPrepareMiewidModel).toHaveBeenCalledWith(latestModelSource); + await waitFor(() => expect(mockAcquireLatestPack).toHaveBeenCalledWith('example-project', withProgress, readyModel)); + expect(mockPrepareMiewidModel).toHaveBeenCalledWith(latestModelSource, withProgress); }, ); @@ -443,7 +447,7 @@ describe('PacksScreen', () => { await waitFor(() => expect(mockAcquireLatestPack).toHaveBeenCalledWith( 'example-project', - {}, + withProgress, readyModel, ), ); @@ -543,7 +547,7 @@ describe('PacksScreen', () => { await waitFor(() => expect(mockAcquireLatestPack).toHaveBeenCalledWith( 'example-project', - {}, + withProgress, readyModel, ), ); @@ -561,7 +565,7 @@ describe('PacksScreen', () => { await waitFor(() => expect(mockAcquireLatestPack).toHaveBeenCalledWith( 'example-project', - {}, + withProgress, readyModel, ), ); @@ -687,11 +691,11 @@ describe('PacksScreen', () => { await waitFor(() => expect(mockAcquireLatestPack).toHaveBeenCalledWith( 'example-project', - {}, + withProgress, readyModel, ), ); - expect(mockPrepareMiewidModel).toHaveBeenCalledWith(latestModelSource); + expect(mockPrepareMiewidModel).toHaveBeenCalledWith(latestModelSource, withProgress); }); it('alerts and stops when resolving the model source fails', async () => { @@ -738,5 +742,49 @@ describe('PacksScreen', () => { await waitFor(() => expect(alertSpy).toHaveBeenCalled()); }); + + it('shows model and then pack progress, with a keep-open hint, while downloading', async () => { + let reportModel: ReportProgress | undefined; + let finishModel: ((record: MiewIDModelRecord) => void) | undefined; + mockPrepareMiewidModel.mockImplementation( + (_source: unknown, opts?: { onProgress?: ReportProgress }) => { + reportModel = opts?.onProgress; + return new Promise(resolve => { + finishModel = resolve; + }); + }, + ); + let reportPack: ReportProgress | undefined; + let finishPack: ((result: { ok: true; pack: EmbeddingPack }) => void) | undefined; + mockAcquireLatestPack.mockImplementation( + (_projectId: string, opts?: { onProgress?: ReportProgress }) => { + reportPack = opts?.onProgress; + return new Promise(resolve => { + finishPack = resolve; + }); + }, + ); + + const { getByTestId, getByText, queryByTestId } = render(); + fireEvent.press(getByTestId('download-pack-button')); + await waitFor(() => expect(reportModel).toBeDefined()); + expect(getByText('Preparing download...')).toBeTruthy(); + + act(() => reportModel?.(85_684_745, 204_011_297)); + expect(getByText('Downloading identification model...')).toBeTruthy(); + expect(getByText('42% (81.7 of 194.6 MB)')).toBeTruthy(); + expect(getByText('Keep EleBook open until this finishes.')).toBeTruthy(); + + await act(async () => finishModel?.(readyModel)); + await waitFor(() => expect(reportPack).toBeDefined()); + expect(getByText('Downloading embedding pack...')).toBeTruthy(); + expect(queryByTestId('pack-download-amount')).toBeNull(); + + act(() => reportPack?.(228_081_202, 228_081_202)); + expect(getByText('Verifying and installing embedding pack...')).toBeTruthy(); + + await act(async () => finishPack?.({ ok: true, pack: createPack() })); + await waitFor(() => expect(queryByTestId('pack-download-status')).toBeNull()); + }); }); }); diff --git a/__tests__/unit/screens/packDownloadProgress.test.ts b/__tests__/unit/screens/packDownloadProgress.test.ts new file mode 100644 index 000000000..5d034ae76 --- /dev/null +++ b/__tests__/unit/screens/packDownloadProgress.test.ts @@ -0,0 +1,50 @@ +import { + describeDownloadAmount, + describeDownloadStage, + progressReporter, +} from '../../../src/screens/packDownloadProgress'; + +const MODEL_BYTES = 204_011_297; +const PACK_BYTES = 228_081_202; + +describe('pack download progress text', () => { + it('describes the preparation step before any transfer has started', () => { + expect(describeDownloadStage(null)).toBe('Preparing download...'); + expect(describeDownloadAmount(null)).toBeNull(); + }); + + it('shows the percentage and 1024-based megabytes, like the pack card', () => { + const progress = { stage: 'model' as const, bytesWritten: 85_684_745, contentLength: MODEL_BYTES }; + + expect(describeDownloadStage(progress)).toBe('Downloading identification model...'); + expect(describeDownloadAmount(progress)).toBe('42% (81.7 of 194.6 MB)'); + }); + + it('switches to verification once every byte has arrived', () => { + const progress = { stage: 'pack' as const, bytesWritten: PACK_BYTES, contentLength: PACK_BYTES }; + + expect(describeDownloadStage(progress)).toBe('Verifying and installing embedding pack...'); + expect(describeDownloadAmount(progress)).toBeNull(); + }); + + it('shows only the received amount when the server sent no length', () => { + const progress = { stage: 'pack' as const, bytesWritten: 12 * 1024 * 1024, contentLength: 0 }; + + expect(describeDownloadStage(progress)).toBe('Downloading embedding pack...'); + expect(describeDownloadAmount(progress)).toBe('12.0 MB'); + }); + + it('hides the amount until the first bytes arrive', () => { + expect( + describeDownloadAmount({ stage: 'pack', bytesWritten: 0, contentLength: PACK_BYTES }), + ).toBeNull(); + }); + + it('tags service progress with its stage', () => { + const onChange = jest.fn(); + + progressReporter('model', onChange)(10, 20); + + expect(onChange).toHaveBeenCalledWith({ stage: 'model', bytesWritten: 10, contentLength: 20 }); + }); +}); diff --git a/__tests__/unit/services/fileDownloadService.test.ts b/__tests__/unit/services/fileDownloadService.test.ts index 5de5d6fcd..b0402d6b9 100644 --- a/__tests__/unit/services/fileDownloadService.test.ts +++ b/__tests__/unit/services/fileDownloadService.test.ts @@ -6,13 +6,49 @@ jest.mock('react-native-fs', () => ({ moveFile: jest.fn(() => Promise.resolve()), downloadFile: jest.fn(), stopDownload: jest.fn(), + resumeDownload: jest.fn(), + completeHandlerIOS: jest.fn(() => Promise.resolve()), })); +import { AppState, Platform } from 'react-native'; +import type { AppStateStatus } from 'react-native'; import RNFS from 'react-native-fs'; -import { downloadFileWithIntegrityCheck } from '../../../src/services/fileDownloadService'; +import { + MAX_IN_PLACE_RESUMES, + downloadFileWithIntegrityCheck, +} from '../../../src/services/fileDownloadService'; const mockDownloadFile = RNFS.downloadFile as jest.Mock; const mockStopDownload = RNFS.stopDownload as jest.Mock; +const mockResumeDownload = RNFS.resumeDownload as jest.Mock; +const mockCompleteHandlerIOS = RNFS.completeHandlerIOS as jest.Mock; + +// An earlier test restores React Native's AppState mock, which leaves it +// returning undefined under Jest 29; stub it so these tests do not depend on order. +const stubAppStateListeners = () => + jest.spyOn(AppState, 'addEventListener').mockImplementation(() => ({ remove: jest.fn() })); + +interface CapturedDownloadOptions { + resumable?: () => void; +} + +/** A native transfer that settles only when the test says so, exposing RNFS's callbacks. */ +function controllableDownload(jobId: number) { + const control: { + options: CapturedDownloadOptions; + finish: (result: { statusCode: number; bytesWritten: number }) => void; + } = { options: {}, finish: () => {} }; + mockDownloadFile.mockImplementation((options: CapturedDownloadOptions) => { + control.options = options; + return { + jobId, + promise: new Promise(resolve => { + control.finish = resolve; + }), + }; + }); + return control; +} const target = { source: { @@ -121,7 +157,6 @@ describe('background transfer', () => { }); it('re-arms the inactivity deadline when the app returns to the foreground', async () => { - const { AppState } = require('react-native'); const addEventListener = jest.spyOn(AppState, 'addEventListener'); mockDownloadFile.mockReturnValue({ jobId: 1, @@ -136,3 +171,149 @@ describe('background transfer', () => { addEventListener.mockRestore(); }); }); + +// RNFS on iOS neither resolves nor rejects a transfer that stopped with resume +// data -- it only calls `resumable`. Azure Blob always allows resuming, so any +// interruption used to leave the Packs screen spinning forever. +describe('interrupted iOS transfers', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + stubAppStateListeners(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resumes an interrupted transfer in place and completes it', async () => { + const download = controllableDownload(7); + + const pending = downloadFileWithIntegrityCheck(target, { maxAttempts: 1 }); + await jest.advanceTimersByTimeAsync(0); + download.options.resumable?.(); + await jest.advanceTimersByTimeAsync(0); + + expect(mockResumeDownload).toHaveBeenCalledWith(7); + download.finish({ statusCode: 200, bytesWritten: 1000 }); + await expect(pending).resolves.toMatchObject({ ok: true }); + expect(mockDownloadFile).toHaveBeenCalledTimes(1); + }); + + it(`fails instead of hanging after ${MAX_IN_PLACE_RESUMES} resumes`, async () => { + const download = controllableDownload(7); + + const pending = downloadFileWithIntegrityCheck(target, { maxAttempts: 1 }); + await jest.advanceTimersByTimeAsync(0); + for (let interruption = 0; interruption <= MAX_IN_PLACE_RESUMES; interruption++) { + download.options.resumable?.(); + await jest.advanceTimersByTimeAsync(0); + } + + await expect(pending).resolves.toMatchObject({ + ok: false, + code: 'network-error', + message: `download interrupted ${MAX_IN_PLACE_RESUMES + 1} times`, + }); + expect(mockResumeDownload).toHaveBeenCalledTimes(MAX_IN_PLACE_RESUMES); + }); + + it('does not resume a transfer that was deliberately stopped', async () => { + const download = controllableDownload(7); + const controller = new AbortController(); + + const pending = downloadFileWithIntegrityCheck(target, { + maxAttempts: 1, + signal: controller.signal, + }); + await jest.advanceTimersByTimeAsync(0); + controller.abort(); + // iOS reports the stop itself as resumable. + download.options.resumable?.(); + + await expect(pending).resolves.toMatchObject({ ok: false, code: 'cancelled' }); + expect(mockStopDownload).toHaveBeenCalledWith(7); + expect(mockResumeDownload).not.toHaveBeenCalled(); + }); + + it('reports completion before hashing so the screen can show verification', async () => { + mockDownloadFile.mockReturnValue({ + jobId: 1, + promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }), + }); + const onProgress = jest.fn(); + + await downloadFileWithIntegrityCheck(target, { onProgress }); + + expect(onProgress).toHaveBeenLastCalledWith(1000, 1000); + }); +}); + +describe('iOS app lifecycle', () => { + const appState = AppState as unknown as { currentState: unknown }; + const originalAppState = appState.currentState; + const originalPlatformOs = Object.getOwnPropertyDescriptor(Platform, 'OS'); + + beforeEach(() => { + jest.clearAllMocks(); + stubAppStateListeners(); + }); + + afterEach(() => { + appState.currentState = originalAppState; + if (originalPlatformOs) { + Object.defineProperty(Platform, 'OS', originalPlatformOs); + } + jest.restoreAllMocks(); + }); + + it('hands iOS its background-session completion handler back after a transfer', async () => { + mockDownloadFile.mockReturnValue({ + jobId: 9, + promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }), + }); + + await downloadFileWithIntegrityCheck(target); + await Promise.resolve(); + + expect(mockCompleteHandlerIOS).toHaveBeenCalledWith(9); + }); + + it('does not call the iOS-only completion handler on Android', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, get: () => 'android' }); + mockDownloadFile.mockReturnValue({ + jobId: 9, + promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }), + }); + + await downloadFileWithIntegrityCheck(target); + await Promise.resolve(); + + expect(mockCompleteHandlerIOS).not.toHaveBeenCalled(); + }); + + it('starts a transfer only once a backgrounded iOS app is back in the foreground', async () => { + const listeners: Array<(state: AppStateStatus) => void> = []; + jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((_type, listener: (state: AppStateStatus) => void) => { + listeners.push(listener); + return { remove: jest.fn() }; + }); + appState.currentState = 'background'; + mockDownloadFile.mockReturnValue({ + jobId: 3, + promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }), + }); + + const pending = downloadFileWithIntegrityCheck(target, { maxAttempts: 1 }); + await new Promise(resolve => setImmediate(resolve)); + expect(mockDownloadFile).not.toHaveBeenCalled(); + + appState.currentState = 'active'; + listeners.forEach(listener => listener('active')); + + await expect(pending).resolves.toMatchObject({ ok: true }); + expect(mockDownloadFile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/unit/utils/appForeground.test.ts b/__tests__/unit/utils/appForeground.test.ts new file mode 100644 index 000000000..f68cc2347 --- /dev/null +++ b/__tests__/unit/utils/appForeground.test.ts @@ -0,0 +1,61 @@ +import { AppState, Platform } from 'react-native'; +import type { AppStateStatus } from 'react-native'; +import { waitForForeground } from '../../../src/utils/appForeground'; + +const appState = AppState as unknown as { currentState: unknown }; +const originalAppState = appState.currentState; +const originalPlatformOs = Object.getOwnPropertyDescriptor(Platform, 'OS'); + +const settled = async (promise: Promise): Promise => { + let done = false; + promise.then(() => { + done = true; + }); + await new Promise(resolve => setImmediate(resolve)); + return done; +}; + +describe('waitForForeground', () => { + afterEach(() => { + appState.currentState = originalAppState; + if (originalPlatformOs) { + Object.defineProperty(Platform, 'OS', originalPlatformOs); + } + jest.restoreAllMocks(); + }); + + it('resolves immediately when the app is already active', async () => { + appState.currentState = 'active'; + + await expect(settled(waitForForeground())).resolves.toBe(true); + }); + + it('waits on iOS until a backgrounded app becomes active, then stops listening', async () => { + const remove = jest.fn(); + let listener: ((state: AppStateStatus) => void) | undefined; + jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((_type, handler: (state: AppStateStatus) => void) => { + listener = handler; + return { remove }; + }); + appState.currentState = 'background'; + + const waiting = waitForForeground(); + await expect(settled(waiting)).resolves.toBe(false); + + listener?.('inactive'); + await expect(settled(waiting)).resolves.toBe(false); + + listener?.('active'); + await expect(settled(waiting)).resolves.toBe(true); + expect(remove).toHaveBeenCalledTimes(1); + }); + + it('never waits on Android, where downloads are not suspended with the app', async () => { + Object.defineProperty(Platform, 'OS', { configurable: true, get: () => 'android' }); + appState.currentState = 'background'; + + await expect(settled(waitForForeground())).resolves.toBe(true); + }); +}); diff --git a/jest.setup.ts b/jest.setup.ts index 5186e26e4..3932ac621 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -255,6 +255,8 @@ jest.mock('react-native-fs', () => ({ promise: Promise.resolve({ statusCode: 200, bytesWritten: 1000 }), })), stopDownload: jest.fn(), + resumeDownload: jest.fn(), + completeHandlerIOS: jest.fn(() => Promise.resolve()), exists: jest.fn(() => Promise.resolve(false)), mkdir: jest.fn(() => Promise.resolve()), unlink: jest.fn(() => Promise.resolve()), diff --git a/src/screens/PackDownloadStatus.tsx b/src/screens/PackDownloadStatus.tsx new file mode 100644 index 000000000..4dcac83e6 --- /dev/null +++ b/src/screens/PackDownloadStatus.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Text, View } from 'react-native'; +import type { ViewStyle } from 'react-native'; +import { useThemedStyles } from '../theme/useThemedStyles'; +import { createStyles } from './PacksScreen.styles'; +import { + KEEP_APP_OPEN_HINT, + describeDownloadAmount, + describeDownloadStage, +} from './packDownloadProgress'; +import type { DownloadProgress } from './packDownloadProgress'; + +interface PackDownloadStatusProps { + progress: DownloadProgress | null; + style?: ViewStyle; +} + +export const PackDownloadStatus: React.FC = ({ + progress, + style, +}) => { + const styles = useThemedStyles(createStyles); + const amount = describeDownloadAmount(progress); + return ( + + + {describeDownloadStage(progress)} + + {amount ? ( + + {amount} + + ) : null} + {KEEP_APP_OPEN_HINT} + + ); +}; diff --git a/src/screens/PacksScreen.styles.ts b/src/screens/PacksScreen.styles.ts index c53f057d4..3f40552d6 100644 --- a/src/screens/PacksScreen.styles.ts +++ b/src/screens/PacksScreen.styles.ts @@ -69,4 +69,13 @@ export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ marginTop: SPACING.lg, minWidth: 220, }, + downloadStatus: { + marginTop: SPACING.md, + }, + downloadDetail: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + textAlign: 'center' as const, + marginTop: SPACING.xs, + }, }); diff --git a/src/screens/PacksScreen.tsx b/src/screens/PacksScreen.tsx index 264ab4af5..fe1c6ecc5 100644 --- a/src/screens/PacksScreen.tsx +++ b/src/screens/PacksScreen.tsx @@ -23,6 +23,9 @@ import { import { ensureSignedIn } from '../utils/authGate'; import logger from '../utils/logger'; import { createStyles } from './PacksScreen.styles'; +import { PackDownloadStatus } from './PackDownloadStatus'; +import { progressReporter } from './packDownloadProgress'; +import type { DownloadProgress } from './packDownloadProgress'; type NavigationProp = NativeStackNavigationProp; @@ -37,6 +40,22 @@ type PackUpdateState = | 'available' | 'unavailable'; +const UPDATE_STATUS_TEXT: Record = { + unchecked: 'Update status not checked', + checking: 'Checking for updates...', + current: 'Up to date', + available: 'Update available', + unavailable: 'Unable to check for updates', +}; + +const UPDATE_BUTTON_TITLE: Record = { + unchecked: 'Check for Updates', + checking: 'Checking for Updates', + current: 'Check Again', + available: 'Update to Latest Pack', + unavailable: 'Check for Updates', +}; + function formatBytes(bytes: number): string { if (bytes < MB) { return `${(bytes / KB).toFixed(1)} KB`; @@ -77,6 +96,8 @@ export const PacksScreen: React.FC = () => { const { packs, miewidModel } = useWildlifeStore(); const preferGpuModel = useAppStore((s) => s.preferGpuModel); const [isDownloading, setIsDownloading] = useState(false); + const [downloadProgress, setDownloadProgress] = + useState(null); const [packUpdateState, setPackUpdateState] = useState('unchecked'); const updateInFlight = useRef(false); @@ -154,7 +175,9 @@ export const PacksScreen: React.FC = () => { // replaced before installing a pack from a newer embedding space. let modelForPack = miewidModel; if (!installedModelIsCurrent) { - modelForPack = await prepareMiewidModel(resolvedSource.source); + modelForPack = await prepareMiewidModel(resolvedSource.source, { + onProgress: progressReporter('model', setDownloadProgress), + }); if (modelForPack.status !== 'ready') { Alert.alert( 'Download failed', @@ -166,9 +189,10 @@ export const PacksScreen: React.FC = () => { } } + setDownloadProgress({ stage: 'pack', bytesWritten: 0, contentLength: 0 }); const packResult = await acquireLatestPack( GANESHA_PROJECT_ID, - {}, + { onProgress: progressReporter('pack', setDownloadProgress) }, modelForPack ?? undefined, ); if (!packResult.ok) { @@ -189,6 +213,7 @@ export const PacksScreen: React.FC = () => { } finally { updateInFlight.current = false; setIsDownloading(false); + setDownloadProgress(null); } }, [miewidModel, navigation, preferGpuModel]); @@ -209,27 +234,6 @@ export const PacksScreen: React.FC = () => { } }, [navigation, refreshPackStatus]); - const updateStatusText = isDownloading - ? 'Downloading and validating update...' - : effectivePackUpdateState === 'checking' - ? 'Checking for updates...' - : effectivePackUpdateState === 'current' - ? 'Up to date' - : effectivePackUpdateState === 'available' - ? 'Update available' - : effectivePackUpdateState === 'unavailable' - ? 'Unable to check for updates' - : 'Update status not checked'; - - const updateButtonTitle = - effectivePackUpdateState === 'current' - ? 'Check Again' - : effectivePackUpdateState === 'available' - ? 'Update to Latest Pack' - : effectivePackUpdateState === 'checking' - ? 'Checking for Updates' - : 'Check for Updates'; - const renderPack = ({ item, index, @@ -277,6 +281,12 @@ export const PacksScreen: React.FC = () => { style={styles.downloadButton} testID="download-pack-button" /> + {isDownloading ? ( + + ) : null} ) : ( { showsVerticalScrollIndicator={false} ListFooterComponent={ - - {updateStatusText} - + {isDownloading ? ( + + ) : ( + + {UPDATE_STATUS_TEXT[effectivePackUpdateState]} + + )}