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]}
+
+ )}