diff --git a/apps/ui/sources/components/ui/lists/MeterBar.tsx b/apps/ui/sources/components/ui/lists/MeterBar.tsx index 384bb407df3..041ec97c7f0 100644 --- a/apps/ui/sources/components/ui/lists/MeterBar.tsx +++ b/apps/ui/sources/components/ui/lists/MeterBar.tsx @@ -77,10 +77,13 @@ export const MeterBar = React.memo((props) => { animatedFill.setValue(fill); return; } + // Transfers report progress every ~100ms and each report restarts this animation. A decelerating + // curve then lurches and stalls on every report; a linear glide spanning a few reports reads as + // one continuous motion. const animation = Animated.timing(animatedFill, { toValue: fill, - duration: motionTokens.durationMs.fast, - easing: motionTokens.easing.standard, + duration: motionTokens.durationMs.slow, + easing: motionTokens.easing.linear, useNativeDriver: Platform.OS !== 'web', }); animation.start(); diff --git a/apps/ui/sources/text/translations/ca.ts b/apps/ui/sources/text/translations/ca.ts index 3bc1e24131c..afa3479ae63 100644 --- a/apps/ui/sources/text/translations/ca.ts +++ b/apps/ui/sources/text/translations/ca.ts @@ -8785,6 +8785,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'fitxer' : 'fitxers'}`, progressAccessibilityLabel: "Progrés de la descàrrega", + verifyingDetail: "Verificant", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verificat ${verified} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'fitxer' : 'fitxers'}`, + verifyingAccessibilityLabel: "Progrés de la verificació", failedDetail: "Ha fallat", failedSubtitle: "No s'ha pogut preparar el model. Toca per tornar-ho a provar.", failedResumableSubtitle: ({ size }: { size: string }) => `La descàrrega no s'ha completat. Es conserven ${size}; toca per reprendre-la.`, diff --git a/apps/ui/sources/text/translations/de.ts b/apps/ui/sources/text/translations/de.ts index 2c1611eeb94..a523cb27e83 100644 --- a/apps/ui/sources/text/translations/de.ts +++ b/apps/ui/sources/text/translations/de.ts @@ -8853,6 +8853,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} von ${total} · ${filesDone} von ${filesTotal} ${filesTotal === 1 ? 'Datei' : 'Dateien'}`, progressAccessibilityLabel: "Download-Fortschritt", + verifyingDetail: "Wird geprüft", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `${verified} von ${total} geprüft · ${filesDone} von ${filesTotal} ${filesTotal === 1 ? 'Datei' : 'Dateien'}`, + verifyingAccessibilityLabel: "Prüffortschritt", failedDetail: "Fehlgeschlagen", failedSubtitle: "Das Modell konnte nicht vorbereitet werden. Tippe, um es erneut zu versuchen.", failedResumableSubtitle: ({ size }: { size: string }) => `Der Download wurde nicht abgeschlossen. ${size} bleiben erhalten – tippe zum Fortsetzen.`, diff --git a/apps/ui/sources/text/translations/en.ts b/apps/ui/sources/text/translations/en.ts index e1cdcda52dc..d2b81db54be 100644 --- a/apps/ui/sources/text/translations/en.ts +++ b/apps/ui/sources/text/translations/en.ts @@ -8885,6 +8885,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} of ${total} · ${filesDone} of ${filesTotal} ${filesTotal === 1 ? 'file' : 'files'}`, progressAccessibilityLabel: "Download progress", + verifyingDetail: "Verifying", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verified ${verified} of ${total} · ${filesDone} of ${filesTotal} ${filesTotal === 1 ? 'file' : 'files'}`, + verifyingAccessibilityLabel: "Verification progress", failedDetail: "Failed", failedSubtitle: "Couldn't get the model ready. Tap to try again.", failedResumableSubtitle: ({ size }: { size: string }) => `Download didn't finish. ${size} is saved, so tap to resume.`, diff --git a/apps/ui/sources/text/translations/es.ts b/apps/ui/sources/text/translations/es.ts index ab3d084975b..5e9e952adb9 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -9336,6 +9336,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'archivo' : 'archivos'}`, progressAccessibilityLabel: "Progreso de la descarga", + verifyingDetail: "Verificando", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verificado ${verified} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'archivo' : 'archivos'}`, + verifyingAccessibilityLabel: "Progreso de la verificación", failedDetail: "Falló", failedSubtitle: "No se pudo preparar el modelo. Toca para volver a intentarlo.", failedResumableSubtitle: ({ size }: { size: string }) => `La descarga no terminó. Se conservan ${size}; toca para reanudarla.`, diff --git a/apps/ui/sources/text/translations/fr.ts b/apps/ui/sources/text/translations/fr.ts index 1d9ae62aec3..d63e8c1fddd 100644 --- a/apps/ui/sources/text/translations/fr.ts +++ b/apps/ui/sources/text/translations/fr.ts @@ -8840,6 +8840,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} sur ${total} · ${filesDone} ${filesDone <= 1 ? 'fichier' : 'fichiers'} sur ${filesTotal}`, progressAccessibilityLabel: "Progression du téléchargement", + verifyingDetail: "Vérification", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `${verified} sur ${total} vérifiés · ${filesDone} ${filesDone <= 1 ? 'fichier' : 'fichiers'} sur ${filesTotal}`, + verifyingAccessibilityLabel: "Progression de la vérification", failedDetail: "Échec", failedSubtitle: "Impossible de préparer le modèle. Touche pour réessayer.", failedResumableSubtitle: ({ size }: { size: string }) => `Le téléchargement n'est pas terminé. ${size} sont conservés ; touche pour reprendre.`, diff --git a/apps/ui/sources/text/translations/it.ts b/apps/ui/sources/text/translations/it.ts index fcc83d0ce63..39aaa6d21f0 100644 --- a/apps/ui/sources/text/translations/it.ts +++ b/apps/ui/sources/text/translations/it.ts @@ -9657,6 +9657,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} di ${total} · ${filesDone} di ${filesTotal} file`, progressAccessibilityLabel: "Avanzamento del download", + verifyingDetail: "Verifica in corso", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verificati ${verified} di ${total} · ${filesDone} di ${filesTotal} file`, + verifyingAccessibilityLabel: "Avanzamento della verifica", failedDetail: "Non riuscito", failedSubtitle: "Impossibile preparare il modello. Tocca per riprovare.", failedResumableSubtitle: ({ size }: { size: string }) => `Il download non è stato completato. ${size} sono stati conservati: tocca per riprendere.`, diff --git a/apps/ui/sources/text/translations/ja.ts b/apps/ui/sources/text/translations/ja.ts index 6d75d0079b2..1288c028de5 100644 --- a/apps/ui/sources/text/translations/ja.ts +++ b/apps/ui/sources/text/translations/ja.ts @@ -9564,6 +9564,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} / ${total} · ${filesDone} / ${filesTotal} ファイル`, progressAccessibilityLabel: "ダウンロードの進行状況", + verifyingDetail: "検証中", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `検証済み ${verified} / ${total} · ${filesDone} / ${filesTotal} ファイル`, + verifyingAccessibilityLabel: "検証の進行状況", failedDetail: "失敗", failedSubtitle: "モデルを準備できませんでした。タップしてもう一度お試しください。", failedResumableSubtitle: ({ size }: { size: string }) => `ダウンロードが完了しませんでした。${size} は保存されています。タップして再開できます。`, diff --git a/apps/ui/sources/text/translations/pl.ts b/apps/ui/sources/text/translations/pl.ts index 5b3fd95b2fc..6957674cbbd 100644 --- a/apps/ui/sources/text/translations/pl.ts +++ b/apps/ui/sources/text/translations/pl.ts @@ -9334,6 +9334,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} z ${total} · ${filesDone} z ${filesTotal} ${filesTotal === 1 ? 'pliku' : 'plików'}`, progressAccessibilityLabel: "Postęp pobierania", + verifyingDetail: "Weryfikacja", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Zweryfikowano ${verified} z ${total} · ${filesDone} z ${filesTotal} ${filesTotal === 1 ? 'pliku' : 'plików'}`, + verifyingAccessibilityLabel: "Postęp weryfikacji", failedDetail: "Niepowodzenie", failedSubtitle: "Nie udało się przygotować modelu. Dotknij, aby spróbować ponownie.", failedResumableSubtitle: ({ size }: { size: string }) => `Pobieranie nie zostało ukończone. Zachowano ${size} — dotknij, aby wznowić.`, diff --git a/apps/ui/sources/text/translations/pt.ts b/apps/ui/sources/text/translations/pt.ts index 7af655343e5..65023744370 100644 --- a/apps/ui/sources/text/translations/pt.ts +++ b/apps/ui/sources/text/translations/pt.ts @@ -9445,6 +9445,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'arquivo' : 'arquivos'}`, progressAccessibilityLabel: "Progresso do download", + verifyingDetail: "Verificando", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Verificado ${verified} de ${total} · ${filesDone} de ${filesTotal} ${filesTotal === 1 ? 'arquivo' : 'arquivos'}`, + verifyingAccessibilityLabel: "Progresso da verificação", failedDetail: "Falhou", failedSubtitle: "Não foi possível preparar o modelo. Toque para tentar novamente.", failedResumableSubtitle: ({ size }: { size: string }) => `O download não terminou. ${size} foram mantidos; toque para retomar.`, diff --git a/apps/ui/sources/text/translations/ru.ts b/apps/ui/sources/text/translations/ru.ts index 51d03987427..b78bc97cc6a 100644 --- a/apps/ui/sources/text/translations/ru.ts +++ b/apps/ui/sources/text/translations/ru.ts @@ -9308,6 +9308,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} из ${total} · ${filesDone} из ${filesTotal} ${plural({ count: filesTotal, one: 'файла', few: 'файлов', many: 'файлов' })}`, progressAccessibilityLabel: "Ход загрузки", + verifyingDetail: "Проверка", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `Проверено ${verified} из ${total} · ${filesDone} из ${filesTotal} ${plural({ count: filesTotal, one: 'файла', few: 'файлов', many: 'файлов' })}`, + verifyingAccessibilityLabel: "Ход проверки", failedDetail: "Ошибка", failedSubtitle: "Не удалось подготовить модель. Нажмите, чтобы повторить.", failedResumableSubtitle: ({ size }: { size: string }) => `Загрузка не завершилась. ${size} сохранено — нажмите, чтобы продолжить.`, diff --git a/apps/ui/sources/text/translations/zh-Hans.ts b/apps/ui/sources/text/translations/zh-Hans.ts index 786b1ece9f0..a11d11e9022 100644 --- a/apps/ui/sources/text/translations/zh-Hans.ts +++ b/apps/ui/sources/text/translations/zh-Hans.ts @@ -9031,6 +9031,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} / ${total} · ${filesDone} / ${filesTotal} 个文件`, progressAccessibilityLabel: "下载进度", + verifyingDetail: "正在校验", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `已校验 ${verified} / ${total} · ${filesDone} / ${filesTotal} 个文件`, + verifyingAccessibilityLabel: "校验进度", failedDetail: "失败", failedSubtitle: "无法准备模型。轻点以重试。", failedResumableSubtitle: ({ size }: { size: string }) => `下载未完成。已保留 ${size},轻点即可继续。`, diff --git a/apps/ui/sources/text/translations/zh-Hant.ts b/apps/ui/sources/text/translations/zh-Hant.ts index 8f2befb9941..bd0dbb407cb 100644 --- a/apps/ui/sources/text/translations/zh-Hant.ts +++ b/apps/ui/sources/text/translations/zh-Hant.ts @@ -7644,6 +7644,10 @@ settingsSession: { progressWithFiles: ({ loaded, total, filesDone, filesTotal }: { loaded: string; total: string; filesDone: number; filesTotal: number }) => `${loaded} / ${total} · ${filesDone} / ${filesTotal} 個檔案`, progressAccessibilityLabel: "下載進度", + verifyingDetail: "正在驗證", + verifyingProgress: ({ verified, total, filesDone, filesTotal }: { verified: string; total: string; filesDone: number; filesTotal: number }) => + `已驗證 ${verified} / ${total} · ${filesDone} / ${filesTotal} 個檔案`, + verifyingAccessibilityLabel: "驗證進度", failedDetail: "失敗", failedSubtitle: "無法準備模型。輕點以重試。", failedResumableSubtitle: ({ size }: { size: string }) => `下載未完成。已保留 ${size},輕點即可繼續。`, diff --git a/apps/ui/sources/voice/downloads/downloadProgress.spec.ts b/apps/ui/sources/voice/downloads/downloadProgress.spec.ts index a3cba23de4e..3977c1c5d86 100644 --- a/apps/ui/sources/voice/downloads/downloadProgress.spec.ts +++ b/apps/ui/sources/voice/downloads/downloadProgress.spec.ts @@ -4,11 +4,11 @@ import { formatDownloadProgressDetail, parseDownloadProgress } from './downloadP describe('parseDownloadProgress', () => { it('parses number fractions as percent', () => { - expect(parseDownloadProgress(0.25)).toEqual({ percent: 25, label: null, bytes: null, files: null }); + expect(parseDownloadProgress(0.25)).toEqual({ percent: 25, label: null, bytes: null, files: null, verifiedBytes: null }); }); it('parses percent numbers directly', () => { - expect(parseDownloadProgress(42)).toEqual({ percent: 42, label: null, bytes: null, files: null }); + expect(parseDownloadProgress(42)).toEqual({ percent: 42, label: null, bytes: null, files: null, verifiedBytes: null }); }); it('parses loaded/total into percent and byte counts', () => { @@ -17,6 +17,7 @@ describe('parseDownloadProgress', () => { label: null, bytes: { loaded: 50, total: 200 }, files: null, + verifiedBytes: null, }); }); @@ -27,6 +28,12 @@ describe('parseDownloadProgress', () => { }); }); + it('parses verified bytes within the transfer size', () => { + expect(parseDownloadProgress({ loaded: 200, total: 200, verified: 150 }).verifiedBytes).toBe(150); + expect(parseDownloadProgress({ loaded: 200, total: 200, verified: 900 }).verifiedBytes).toBe(200); + expect(parseDownloadProgress({ loaded: 200, total: 200 }).verifiedBytes).toBeNull(); + }); + it('ignores file counts that cannot describe a transfer', () => { expect(parseDownloadProgress({ loaded: 1, total: 2, filesDone: 0, filesTotal: 0 }).files).toBeNull(); expect(parseDownloadProgress({ loaded: 1, total: 2, filesDone: Number.NaN, filesTotal: 4 }).files).toBeNull(); @@ -38,6 +45,7 @@ describe('parseDownloadProgress', () => { label: 'model_quantized.onnx', bytes: null, files: null, + verifiedBytes: null, }); }); @@ -47,6 +55,7 @@ describe('parseDownloadProgress', () => { label: 'af_heart.bin', bytes: null, files: null, + verifiedBytes: null, }); }); }); diff --git a/apps/ui/sources/voice/downloads/downloadProgress.ts b/apps/ui/sources/voice/downloads/downloadProgress.ts index ed302d4a4ee..2e741557faf 100644 --- a/apps/ui/sources/voice/downloads/downloadProgress.ts +++ b/apps/ui/sources/voice/downloads/downloadProgress.ts @@ -5,6 +5,8 @@ export type DownloadProgressInfo = { bytes: { loaded: number; total: number } | null; /** Completed and total files, when the source reports a multi-file transfer. */ files: { done: number; total: number } | null; + /** Bytes already checked against their expected hash, when the source verifies what it downloads. */ + verifiedBytes: number | null; }; function clampPercent(x: number): number { @@ -35,10 +37,10 @@ export function parseDownloadProgress(progress: unknown): DownloadProgressInfo { if (typeof progress === 'number' && Number.isFinite(progress)) { const isFraction = progress >= 0 && progress <= 1; const percent = clampPercent(Math.floor((isFraction ? progress * 100 : progress) as number)); - return { percent, label: null, bytes: null, files: null }; + return { percent, label: null, bytes: null, files: null, verifiedBytes: null }; } - if (!progress || typeof progress !== 'object') return { percent: null, label: null, bytes: null, files: null }; + if (!progress || typeof progress !== 'object') return { percent: null, label: null, bytes: null, files: null, verifiedBytes: null }; const obj = progress as Record; @@ -58,17 +60,19 @@ export function parseDownloadProgress(progress: unknown): DownloadProgressInfo { const total = obj?.total; if (isFiniteNumber(loaded) && isFiniteNumber(total) && total > 0) { const percent = clampPercent(Math.floor((loaded / total) * 100)); - return { percent, label, bytes: { loaded: Math.min(Math.max(0, loaded), total), total }, files }; + const verified = obj?.verified; + const verifiedBytes = isFiniteNumber(verified) ? Math.min(Math.max(0, verified), total) : null; + return { percent, label, bytes: { loaded: Math.min(Math.max(0, loaded), total), total }, files, verifiedBytes }; } const rawProgress = obj?.progress; if (isFiniteNumber(rawProgress)) { const isFraction = rawProgress >= 0 && rawProgress <= 1; const percent = clampPercent(Math.floor((isFraction ? rawProgress * 100 : rawProgress) as number)); - return { percent, label, bytes: null, files }; + return { percent, label, bytes: null, files, verifiedBytes: null }; } - return { percent: null, label, bytes: null, files }; + return { percent: null, label, bytes: null, files, verifiedBytes: null }; } export function formatDownloadProgressDetail(progress: unknown, opts?: { prefix?: string }): string { diff --git a/apps/ui/sources/voice/modelPacks/installer.nativeDownload.spec.ts b/apps/ui/sources/voice/modelPacks/installer.nativeDownload.spec.ts index d42949c041e..66542000270 100644 --- a/apps/ui/sources/voice/modelPacks/installer.nativeDownload.spec.ts +++ b/apps/ui/sources/voice/modelPacks/installer.nativeDownload.spec.ts @@ -171,11 +171,12 @@ describe('native model pack file downloads', () => { if (platform === 'ios') expect(harness.nativeOptions[0]?.sessionType).toBe(1); expect(harness.files.get(harness.fileUri)).toEqual(harness.bytes); expect(harness.files.has(harness.metaUri)).toBe(true); - // Bytes arrive while the file transfers; it counts as done only once verified. + // Bytes arrive while the file transfers, then hashing reports verified bytes; it counts as done only once verified. expect(progress).toEqual([ - { loaded: 2, total: 4, filesDone: 0, filesTotal: 1 }, - { loaded: 4, total: 4, filesDone: 0, filesTotal: 1 }, - { loaded: 4, total: 4, filesDone: 1, filesTotal: 1 }, + { loaded: 2, total: 4, verified: 0, filesDone: 0, filesTotal: 1 }, + { loaded: 4, total: 4, verified: 0, filesDone: 0, filesTotal: 1 }, + { loaded: 4, total: 4, verified: 4, filesDone: 0, filesTotal: 1 }, + { loaded: 4, total: 4, verified: 4, filesDone: 1, filesTotal: 1 }, ]); expect(harness.lifecycle).toMatchObject({ subscribed: false, retainedNativeTask: false }); }); diff --git a/apps/ui/sources/voice/modelPacks/installer.spec.ts b/apps/ui/sources/voice/modelPacks/installer.spec.ts index ea4909de85d..2e335aa7ce0 100644 --- a/apps/ui/sources/voice/modelPacks/installer.spec.ts +++ b/apps/ui/sources/voice/modelPacks/installer.spec.ts @@ -199,6 +199,68 @@ describe('modelPacks installer (native)', () => { expect(Buffer.from(fs.files.get(`${PACK_URI}/downloaded.bin`) ?? []).equals(Buffer.from(downloaded))).toBe(true); }); + it('reports verification progress while a downloaded file is hashed', async () => { + const bytes = multiChunkBytes(3); + const fs = createFs(); + useNativeDownloads(fs, () => bytes); + const progress: Array<{ verified: number; filesDone: number }> = []; + await ensureModelPackInstalled({ + ...installOptions, signal: new AbortController().signal, + onProgress: (p) => progress.push({ verified: p.verified, filesDone: p.filesDone }), + }, { + fs, fetch: fetchManifest(manifest([ + { path: 'model.onnx', url: 'https://example.com/model.onnx', sha256: await sha256Hex(bytes), sizeBytes: bytes.length }, + ])), + }); + expect(progress.some((p) => p.filesDone === 0 && p.verified > 0 && p.verified < bytes.length)).toBe(true); + expect(progress.at(-1)).toEqual({ verified: bytes.length, filesDone: 1 }); + }); + + it('advances progress while checking files an interrupted install left on disk', async () => { + const kept = multiChunkBytes(4); + const stale = multiChunkBytes(5); + const fresh = multiChunkBytes(6); + const fs = createFs(new Map([[`${PACK_URI}/kept.bin`, kept], [`${PACK_URI}/stale.bin`, stale]])); + useNativeDownloads(fs, () => fresh); + const progress: Array<{ loaded: number; total: number; filesDone: number }> = []; + await ensureModelPackInstalled({ + ...installOptions, signal: new AbortController().signal, + onProgress: (p) => progress.push({ loaded: p.loaded, total: p.total, filesDone: p.filesDone }), + }, { + fs, fetch: fetchManifest(manifest([ + { path: 'kept.bin', url: 'https://example.com/kept.bin', sha256: await sha256Hex(kept), sizeBytes: kept.length }, + { path: 'stale.bin', url: 'https://example.com/stale.bin', sha256: await sha256Hex(fresh), sizeBytes: fresh.length }, + ])), + }); + // Checking a kept file moves the bar instead of leaving it frozen until the whole file is hashed. + expect(progress.some((p) => p.filesDone === 0 && p.loaded > 0 && p.loaded < kept.length)).toBe(true); + // A rejected file's checked bytes are withdrawn, so its redownload is not counted twice. + expect(progress.every((p) => p.loaded <= p.total)).toBe(true); + expect(progress.at(-1)).toEqual({ loaded: kept.length + fresh.length, total: kept.length + fresh.length, filesDone: 2 }); + }); + + it('starts the largest files first so they transfer alongside the small ones', async () => { + const small = new Uint8Array([1]); + const medium = new Uint8Array(16).fill(2); + const large = new Uint8Array(64).fill(3); + const byUrl = new Map([['https://example.com/large', large], ['https://example.com/medium', medium]]); + const fs = createFs(); + const requested: string[] = []; + useNativeDownloads(fs, (url) => byUrl.get(url) ?? small, requested); + const smallEntries = await Promise.all(Array.from({ length: 8 }, async (_, i) => ({ + path: `espeak-ng-data/f${i}`, url: `https://example.com/f${i}`, sha256: await sha256Hex(small), sizeBytes: small.length, + }))); + await ensureModelPackInstalled({ ...installOptions, signal: new AbortController().signal }, { + fs, fetch: fetchManifest(manifest([ + ...smallEntries, + { path: 'model.onnx', url: 'https://example.com/large', sha256: await sha256Hex(large), sizeBytes: large.length }, + { path: 'voices.bin', url: 'https://example.com/medium', sha256: await sha256Hex(medium), sizeBytes: medium.length }, + ])), + }); + expect(requested.slice(0, 2)).toEqual(['https://example.com/large', 'https://example.com/medium']); + expect(requested).toHaveLength(10); + }); + it('rejects and removes a downloaded file whose SHA-256 does not match', async () => { const fs = createFs(); useNativeDownloads(fs, () => new Uint8Array([4, 5, 6, 7])); @@ -351,7 +413,7 @@ describe('modelPacks installer (native)', () => { useInstallerFakeClock(); const { bytes, manifest: remote } = await packOf(2); const fs = createFs(); - const progress: Array<{ ratio: number; filesDone: number }> = []; + const progress: Array<{ ratio: number; verified: number; filesDone: number }> = []; // A 500 error page is far larger than a tiny espeak-ng asset. useNativeDownloads(fs, () => bytes, [], { status: (url) => (url === 'https://example.com/f0' ? 500 : 200), @@ -359,12 +421,12 @@ describe('modelPacks installer (native)', () => { }); await settleWithFakeClock(ensureModelPackInstalled({ ...installOptions, signal: new AbortController().signal, - onProgress: (p) => progress.push({ ratio: p.loaded / p.total, filesDone: p.filesDone }), + onProgress: (p) => progress.push({ ratio: p.loaded / p.total, verified: p.verified, filesDone: p.filesDone }), }, { fs, fetch: fetchManifest(remote) })); expect(Math.max(...progress.map((p) => p.ratio))).toBe(1); // A retried attempt re-reporting bytes already counted adds no report of its own. - expect(new Set(progress.map((p) => `${p.ratio}:${p.filesDone}`)).size).toBe(progress.length); - expect(progress.at(-1)).toEqual({ ratio: 1, filesDone: 2 }); + expect(new Set(progress.map((p) => `${p.ratio}:${p.verified}:${p.filesDone}`)).size).toBe(progress.length); + expect(progress.at(-1)).toEqual({ ratio: 1, verified: bytes.length * 2, filesDone: 2 }); }); it('removes update leftovers once a fresh install completes', async () => { diff --git a/apps/ui/sources/voice/modelPacks/installer/download.native.ts b/apps/ui/sources/voice/modelPacks/installer/download.native.ts index 6114d20625a..bd79bb0d417 100644 --- a/apps/ui/sources/voice/modelPacks/installer/download.native.ts +++ b/apps/ui/sources/voice/modelPacks/installer/download.native.ts @@ -54,15 +54,22 @@ async function abortableDelay(ms: number, signal: AbortSignal): Promise { throwIfAborted(signal); } -async function sha256FileHex(file: Pick, signal: AbortSignal): Promise { +async function sha256FileHex( + file: Pick, + signal: AbortSignal, + onHashed?: (bytes: number) => void, +): Promise { const hash = sha256.create(); const handle = file.open(); + let hashed = 0; try { while (true) { throwIfAborted(signal); const chunk: Uint8Array = handle.readBytes(HASH_READ_CHUNK_BYTES); if (chunk.length === 0) break; hash.update(chunk); + hashed += chunk.length; + onHashed?.(hashed); // `readBytes` is synchronous; yield between chunks so hashing does not block the JS thread. await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -166,11 +173,15 @@ async function downloadAndVerifyFile(opts: { entry: ModelPackManifest['files'][number]; signal: AbortSignal; onProgress?: (p: Progress) => void; + /** Bytes hashed by the current verification pass; a repeated pass starts again from 0. */ + onVerifyProgress?: (bytes: number, source: 'existing' | 'downloaded') => void; + /** A file left on disk failed verification and is downloaded again. */ + onExistingRejected?: () => void; }): Promise { const file = getPackFile(opts.fs, opts.rootDir, opts.entry.path, { createParents: true }); if (file.exists) { try { - const actual = await sha256FileHex(file, opts.signal); + const actual = await sha256FileHex(file, opts.signal, (bytes) => opts.onVerifyProgress?.(bytes, 'existing')); if (actual === opts.entry.sha256.toLowerCase()) return; file.delete(); } catch (error) { @@ -183,6 +194,7 @@ async function downloadAndVerifyFile(opts: { console.warn('[modelPacks] Failed to remove unverified model pack file', cleanupError); } } + opts.onExistingRejected?.(); } try { @@ -191,7 +203,7 @@ async function downloadAndVerifyFile(opts: { downloader: opts.downloader, url: opts.entry.url, fileUri: file.uri, signal: opts.signal, onProgress: opts.onProgress, }); - const actual = await sha256FileHex(file, opts.signal); + const actual = await sha256FileHex(file, opts.signal, (bytes) => opts.onVerifyProgress?.(bytes, 'downloaded')); if (actual !== opts.entry.sha256.toLowerCase()) { throw new Error('model_pack_sha256_mismatch'); } @@ -212,11 +224,16 @@ export async function downloadManifestFiles(opts: { signal: AbortSignal; onProgress?: (p: ModelPackDownloadProgress) => void; }): Promise { - const total = opts.manifest.files.reduce((acc, f) => acc + (Number.isFinite(f.sizeBytes) ? f.sizeBytes : 0), 0); - const filesTotal = opts.manifest.files.length; + const sizeOf = (f: ModelPackManifest['files'][number]) => (Number.isFinite(f.sizeBytes) ? f.sizeBytes : 0); + // Largest first: a pack's few big files dominate its transfer and verification time, so they start at + // once while the remaining workers drain the many small files, instead of starting alone at the end. + const files = [...opts.manifest.files].sort((a, b) => sizeOf(b) - sizeOf(a)); + const total = files.reduce((acc, f) => acc + sizeOf(f), 0); + const filesTotal = files.length; let loadedTotal = 0; + let verifiedTotal = 0; let filesDone = 0; - const report = () => opts.onProgress?.({ loaded: loadedTotal, total, filesDone, filesTotal }); + const report = () => opts.onProgress?.({ loaded: loadedTotal, total, verified: verifiedTotal, filesDone, filesTotal }); throwIfAborted(opts.signal); const downloader = await import('expo-file-system/legacy'); // The first fatal failure stops sibling transfers; the pack then reports that failure, not their aborts. @@ -236,6 +253,13 @@ export async function downloadManifestFiles(opts: { loadedTotal += delta; return true; }; + let verifiedThisFile = 0; + // Unlike transfer bytes, verification restarts from 0 when a stale file is rehashed after redownload. + const setVerified = (verified: number) => { + verifiedTotal += verified - verifiedThisFile; + verifiedThisFile = verified; + }; + const clampToFile = (bytes: number) => (Number.isFinite(f.sizeBytes) ? Math.min(bytes, f.sizeBytes) : bytes); await downloadAndVerifyFile({ downloader, fs: opts.fs, @@ -244,18 +268,33 @@ export async function downloadManifestFiles(opts: { signal: pack.signal, // Native counts error-response bodies too; a failed attempt must not report beyond the asset. onProgress: (p) => { - if (advance(Number.isFinite(f.sizeBytes) ? Math.min(p.loaded, f.sizeBytes) : p.loaded)) report(); + if (advance(clampToFile(p.loaded))) report(); + }, + onVerifyProgress: (bytes, source) => { + setVerified(clampToFile(bytes)); + // Bytes an interrupted install left on disk are already present; checking them advances the + // transfer too, so resuming over a large kept file does not freeze the bar while it is hashed. + if (source === 'existing') advance(clampToFile(bytes)); + report(); + }, + onExistingRejected: () => { + // The kept bytes turned out unusable; withdraw them so the redownload is not counted twice. + loadedTotal -= loadedThisFile; + loadedThisFile = 0; + setVerified(0); + report(); }, }); // Verified files count fully, including ones already present from an interrupted install. advance(Number.isFinite(f.sizeBytes) ? f.sizeBytes : loadedThisFile); + setVerified(Number.isFinite(f.sizeBytes) ? f.sizeBytes : verifiedThisFile); filesDone += 1; report(); }; const worker = async () => { - while (!pack.signal.aborted && next < opts.manifest.files.length) { - const f = opts.manifest.files[next++]!; + while (!pack.signal.aborted && next < files.length) { + const f = files[next++]!; try { await installFile(f); } catch (error) { @@ -266,7 +305,7 @@ export async function downloadManifestFiles(opts: { }; try { - const workerCount = Math.min(MAX_PARALLEL_FILE_DOWNLOADS, opts.manifest.files.length); + const workerCount = Math.min(MAX_PARALLEL_FILE_DOWNLOADS, files.length); await Promise.all(Array.from({ length: workerCount }, worker)); } finally { opts.signal.removeEventListener('abort', forwardAbort); diff --git a/apps/ui/sources/voice/modelPacks/installer/types.ts b/apps/ui/sources/voice/modelPacks/installer/types.ts index 633684b3664..5ba91cceaee 100644 --- a/apps/ui/sources/voice/modelPacks/installer/types.ts +++ b/apps/ui/sources/voice/modelPacks/installer/types.ts @@ -25,6 +25,15 @@ export type InstallerOverrides = { export type Progress = { loaded: number; total: number }; -/** Pack-level progress: bytes are clamped to the manifest sizes; verified files count as done. */ -export type ModelPackDownloadProgress = Readonly<{ loaded: number; total: number; filesDone: number; filesTotal: number }>; +/** + * Pack-level progress: bytes are clamped to the manifest sizes; verified files count as done. `verified` counts + * bytes already checked against the manifest hash, which trails `loaded` while downloaded files are hashed. + */ +export type ModelPackDownloadProgress = Readonly<{ + loaded: number; + total: number; + verified: number; + filesDone: number; + filesTotal: number; +}>; diff --git a/apps/ui/sources/voice/settings/panels/localTts/LocalNeuralTtsSettings.native.spec.tsx b/apps/ui/sources/voice/settings/panels/localTts/LocalNeuralTtsSettings.native.spec.tsx index 83f24f2c59b..36099dc1804 100644 --- a/apps/ui/sources/voice/settings/panels/localTts/LocalNeuralTtsSettings.native.spec.tsx +++ b/apps/ui/sources/voice/settings/panels/localTts/LocalNeuralTtsSettings.native.spec.tsx @@ -56,6 +56,28 @@ describe('LocalNeuralTtsSettings through device boundaries', () => { expect([...device.files.keys()].some(path => path.includes('/modelPackUpdates/'))).toBe(false); }); + it('shows a verifying phase once every byte has arrived but files are still being checked', async () => { + seedInstalledPack(packId, 'tts'); + device.fetch.mockImplementation(async () => Response.json(modelPackManifest(packId, 'tts'))); + const { screen, row } = await renderSettings(); + await act(async () => { + await pressTestInstanceAsync(row(t('settingsVoice.local.kokoro.updates.title'))); + await vi.waitFor(() => expect(device.transfers.length).toBe(2)); + }); + await act(async () => { + device.transfers[0]!.report(4, 4); + device.transfers[1]!.report(4, 4); + }); + expect(row(modelTitle()).props.detail).toBe(t('settingsVoice.local.modelPackDownload.verifyingDetail')); + expect(collectText(row(modelTitle()))).toContain(t('settingsVoice.local.modelPackDownload.verifyingProgress', { + verified: '0 B', total: '8 B', filesDone: 0, filesTotal: 2, + })); + expect(screen.findAll(n => n.props.accessibilityRole === 'progressbar')[0]?.props.accessibilityValue) + .toEqual({ min: 0, max: 100, now: 0 }); + await act(async () => { row(modelTitle()).props.rightElement.props.onPress(); }); + await vi.waitFor(() => expect(row(modelTitle()).props.detail).toBe(t('settingsVoice.local.kokoro.modelStatus.ready'))); + }); + it('shows a partial download from an earlier session and discards its actual files after confirmation', async () => { device.files.set(`${voiceFilesRoot}/modelPacks/${packId}/saved.bin`, new Uint8Array(3)); const { row } = await renderSettings(); diff --git a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx index e00833ab211..38c7f75c255 100644 --- a/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx +++ b/apps/ui/sources/voice/settings/panels/modelPack/modelPackTransferRows.tsx @@ -34,32 +34,63 @@ export function readPartialDownloadBytes(summary: Readonly<{ installed: boolean; return summary.partialBytes ?? 0; } -function ModelPackProgressBar(props: Readonly<{ progress: number }>) { +function ModelPackProgressBar(props: Readonly<{ progress: number; accessibilityLabel: string }>) { const { theme } = useUnistyles(); return ; + progressAccessibilityLabel={props.accessibilityLabel} />; +} + +function progressSubtitle( + phase: 'download' | 'verify', + fraction: number, + accessibilityLabel: string, + line: string, +): React.ReactNode { + // Item renders the string with its own subtitle typography below the bar. + return ( + <> + + {/* The bar's meaning changes between phases; a fresh bar starts at the new value instead of + gliding backward from a full download bar to the verification still in progress. */} + + + {line} + + ); } function describeDownloading(progress: unknown): ModelPackRowPresentation { const info = parseDownloadProgress(progress); const detail = info.percent != null ? `${info.percent}%` : t('settingsVoice.local.kokoro.modelStatus.downloading'); if (!info.bytes) return { detail, subtitle: null }; - const loaded = formatByteSize(info.bytes.loaded); const total = formatByteSize(info.bytes.total); + // Every byte has arrived but files are still being hashed: large files take a while to check, so show + // that work instead of a download stuck at 100%. + if (info.files && info.verifiedBytes != null && info.bytes.loaded >= info.bytes.total && info.files.done < info.files.total) { + return { + detail: t('settingsVoice.local.modelPackDownload.verifyingDetail'), + subtitle: progressSubtitle( + 'verify', + info.verifiedBytes / info.bytes.total, + t('settingsVoice.local.modelPackDownload.verifyingAccessibilityLabel'), + t('settingsVoice.local.modelPackDownload.verifyingProgress', { + verified: formatByteSize(info.verifiedBytes), total, filesDone: info.files.done, filesTotal: info.files.total, + }), + ), + }; + } + const loaded = formatByteSize(info.bytes.loaded); const line = info.files ? t('settingsVoice.local.modelPackDownload.progressWithFiles', { loaded, total, filesDone: info.files.done, filesTotal: info.files.total }) : t('settingsVoice.local.modelPackDownload.progress', { loaded, total }); return { detail, - // Item renders the string with its own subtitle typography below the bar. - subtitle: ( - <> - - - - {line} - + subtitle: progressSubtitle( + 'download', + info.bytes.loaded / info.bytes.total, + t('settingsVoice.local.modelPackDownload.progressAccessibilityLabel'), + line, ), }; }