From 23af324d79ba60a42af9519d80bbd08d20b24896 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:02:10 +0200 Subject: [PATCH] fix: verify app updates without a second slow progress bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since releases ship an `.apk.sha256` file, every in-app update checks the downloaded APK against it. The check read the 40 MB file in base64 chunks and hashed it in JS, which took longer than the download, and it reset the progress bar to 0 under the "Downloading" label, so the update looked like it downloaded twice, the second time slowly. The file is now read and hashed natively through expo-crypto (the JS hasher remains as a fallback), and the bar stays full under a "Verifying…" label while the check runs. --- locales/rn/ar.json | 3 +- locales/rn/ca.json | 3 +- locales/rn/cs.json | 3 +- locales/rn/da.json | 3 +- locales/rn/de.json | 3 +- locales/rn/en.json | 3 +- locales/rn/es.json | 3 +- locales/rn/fa.json | 3 +- locales/rn/fr.json | 3 +- locales/rn/he.json | 3 +- locales/rn/hu.json | 3 +- locales/rn/it.json | 3 +- locales/rn/ja.json | 3 +- locales/rn/ko.json | 3 +- locales/rn/lv.json | 3 +- locales/rn/mn.json | 3 +- locales/rn/nb.json | 3 +- locales/rn/nl.json | 3 +- locales/rn/pl.json | 3 +- locales/rn/pt.json | 3 +- locales/rn/ro.json | 3 +- locales/rn/ru.json | 3 +- locales/rn/sk.json | 3 +- locales/rn/tr.json | 3 +- locales/rn/uk.json | 3 +- locales/rn/zh-TW.json | 3 +- locales/rn/zh.json | 3 +- src/components/settings/UpdatesSettings.tsx | 13 ++++-- src/lib/__tests__/install-update.test.ts | 49 ++++++++++++++++++++ src/lib/install-update.ts | 51 ++++++++------------- src/lib/sha256.ts | 9 ++-- 31 files changed, 135 insertions(+), 68 deletions(-) create mode 100644 src/lib/__tests__/install-update.test.ts diff --git a/locales/rn/ar.json b/locales/rn/ar.json index 91d58ac1..96ea46ea 100644 --- a/locales/rn/ar.json +++ b/locales/rn/ar.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.", "severity_deprecated": "إلزامي", "severity_security": "أمني", - "title": "تحديثات التطبيق" + "title": "تحديثات التطبيق", + "verifying": "جارٍ التحقق…" } } diff --git a/locales/rn/ca.json b/locales/rn/ca.json index d06d7a6a..1f24ae9c 100644 --- a/locales/rn/ca.json +++ b/locales/rn/ca.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Seguiu el progrés de la compilació o baixeu l'APK manualment.", "severity_deprecated": "Obligatòria", "severity_security": "Seguretat", - "title": "Actualitzacions de l'aplicació" + "title": "Actualitzacions de l'aplicació", + "verifying": "S'està verificant…" } } diff --git a/locales/rn/cs.json b/locales/rn/cs.json index ff69bc4c..e33381f9 100644 --- a/locales/rn/cs.json +++ b/locales/rn/cs.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Sledujte průběh sestavení nebo si stáhněte APK ručně.", "severity_deprecated": "Povinná", "severity_security": "Zabezpečení", - "title": "Aktualizace aplikace" + "title": "Aktualizace aplikace", + "verifying": "Ověřování…" } } diff --git a/locales/rn/da.json b/locales/rn/da.json index ee3d7acd..3e8ea243 100644 --- a/locales/rn/da.json +++ b/locales/rn/da.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Følg buildets fremskridt, eller download APK'en manuelt.", "severity_deprecated": "Påkrævet", "severity_security": "Sikkerhed", - "title": "Appopdateringer" + "title": "Appopdateringer", + "verifying": "Verificerer…" } } diff --git a/locales/rn/de.json b/locales/rn/de.json index b79f0dd7..e738a8d6 100644 --- a/locales/rn/de.json +++ b/locales/rn/de.json @@ -1168,7 +1168,8 @@ "release_page_pending_desc": "Den Build-Fortschritt verfolgen oder die APK manuell herunterladen.", "severity_deprecated": "Erforderlich", "severity_security": "Sicherheit", - "title": "App-Updates" + "title": "App-Updates", + "verifying": "Wird überprüft…" }, "widgets": { "attachments": { diff --git a/locales/rn/en.json b/locales/rn/en.json index 1a7c8f68..eedb5336 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1184,7 +1184,8 @@ "release_page_pending_desc": "Watch the build progress or download the APK manually.", "severity_deprecated": "Required", "severity_security": "Security", - "title": "App updates" + "title": "App updates", + "verifying": "Verifying…" }, "widgets": { "attachments": { diff --git a/locales/rn/es.json b/locales/rn/es.json index 226618f2..ef29641e 100644 --- a/locales/rn/es.json +++ b/locales/rn/es.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Sigue el progreso de la compilación o descarga el APK manualmente.", "severity_deprecated": "Obligatoria", "severity_security": "Seguridad", - "title": "Actualizaciones de la app" + "title": "Actualizaciones de la app", + "verifying": "Verificando…" } } diff --git a/locales/rn/fa.json b/locales/rn/fa.json index 1b84c676..23d47354 100644 --- a/locales/rn/fa.json +++ b/locales/rn/fa.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.", "severity_deprecated": "الزامی", "severity_security": "امنیتی", - "title": "به‌روزرسانی‌های برنامه" + "title": "به‌روزرسانی‌های برنامه", + "verifying": "در حال بررسی…" } } diff --git a/locales/rn/fr.json b/locales/rn/fr.json index 45d80706..226ca38e 100644 --- a/locales/rn/fr.json +++ b/locales/rn/fr.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Suivez l'avancement de la compilation ou téléchargez l'APK manuellement.", "severity_deprecated": "Requise", "severity_security": "Sécurité", - "title": "Mises à jour de l'application" + "title": "Mises à jour de l'application", + "verifying": "Vérification…" } } diff --git a/locales/rn/he.json b/locales/rn/he.json index 7062f221..74fbf9ad 100644 --- a/locales/rn/he.json +++ b/locales/rn/he.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.", "severity_deprecated": "חובה", "severity_security": "אבטחה", - "title": "עדכוני אפליקציה" + "title": "עדכוני אפליקציה", + "verifying": "מאמת…" } } diff --git a/locales/rn/hu.json b/locales/rn/hu.json index c1f5ce2d..cdf2a9ec 100644 --- a/locales/rn/hu.json +++ b/locales/rn/hu.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Kövesd a build folyamatát, vagy töltsd le kézzel az APK-t.", "severity_deprecated": "Kötelező", "severity_security": "Biztonsági", - "title": "Alkalmazásfrissítések" + "title": "Alkalmazásfrissítések", + "verifying": "Ellenőrzés…" } } diff --git a/locales/rn/it.json b/locales/rn/it.json index 53e7b8de..ac84ea94 100644 --- a/locales/rn/it.json +++ b/locales/rn/it.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Segui l'avanzamento della build o scarica l'APK manualmente.", "severity_deprecated": "Obbligatorio", "severity_security": "Sicurezza", - "title": "Aggiornamenti dell'app" + "title": "Aggiornamenti dell'app", + "verifying": "Verifica in corso…" } } diff --git a/locales/rn/ja.json b/locales/rn/ja.json index 24492c76..a3409565 100644 --- a/locales/rn/ja.json +++ b/locales/rn/ja.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。", "severity_deprecated": "必須", "severity_security": "セキュリティ", - "title": "アプリのアップデート" + "title": "アプリのアップデート", + "verifying": "検証中…" } } diff --git a/locales/rn/ko.json b/locales/rn/ko.json index 398cc7c2..c4664b73 100644 --- a/locales/rn/ko.json +++ b/locales/rn/ko.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.", "severity_deprecated": "필수", "severity_security": "보안", - "title": "앱 업데이트" + "title": "앱 업데이트", + "verifying": "확인 중…" } } diff --git a/locales/rn/lv.json b/locales/rn/lv.json index 8b96cb74..ef5fb809 100644 --- a/locales/rn/lv.json +++ b/locales/rn/lv.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Sekojiet būvēšanas gaitai vai lejupielādējiet APK manuāli.", "severity_deprecated": "Obligāts", "severity_security": "Drošība", - "title": "Lietotnes atjauninājumi" + "title": "Lietotnes atjauninājumi", + "verifying": "Pārbauda…" } } diff --git a/locales/rn/mn.json b/locales/rn/mn.json index 80cb842e..73ba4774 100644 --- a/locales/rn/mn.json +++ b/locales/rn/mn.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.", "severity_deprecated": "Заавал", "severity_security": "Аюулгүй байдал", - "title": "Аппын шинэчлэл" + "title": "Аппын шинэчлэл", + "verifying": "Шалгаж байна…" } } diff --git a/locales/rn/nb.json b/locales/rn/nb.json index 81079400..16443226 100644 --- a/locales/rn/nb.json +++ b/locales/rn/nb.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Følg fremdriften for byggingen, eller last ned APK-filen manuelt.", "severity_deprecated": "Påkrevd", "severity_security": "Sikkerhet", - "title": "Appoppdateringer" + "title": "Appoppdateringer", + "verifying": "Verifiserer…" } } diff --git a/locales/rn/nl.json b/locales/rn/nl.json index b06d9047..efbca3fd 100644 --- a/locales/rn/nl.json +++ b/locales/rn/nl.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Volg de voortgang van de build of download de APK handmatig.", "severity_deprecated": "Vereist", "severity_security": "Beveiliging", - "title": "App-updates" + "title": "App-updates", + "verifying": "Controleren…" } } diff --git a/locales/rn/pl.json b/locales/rn/pl.json index 05bc2289..42bf12a8 100644 --- a/locales/rn/pl.json +++ b/locales/rn/pl.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Śledź postęp budowania lub pobierz plik APK ręcznie.", "severity_deprecated": "Wymagana", "severity_security": "Bezpieczeństwo", - "title": "Aktualizacje aplikacji" + "title": "Aktualizacje aplikacji", + "verifying": "Weryfikowanie…" } } diff --git a/locales/rn/pt.json b/locales/rn/pt.json index 931bf7f7..84f7918a 100644 --- a/locales/rn/pt.json +++ b/locales/rn/pt.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Acompanhe o progresso da compilação ou baixe o APK manualmente.", "severity_deprecated": "Obrigatória", "severity_security": "Segurança", - "title": "Atualizações do app" + "title": "Atualizações do app", + "verifying": "Verificando…" } } diff --git a/locales/rn/ro.json b/locales/rn/ro.json index ed7df107..1cab3248 100644 --- a/locales/rn/ro.json +++ b/locales/rn/ro.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Urmăriți progresul compilării sau descărcați manual APK-ul.", "severity_deprecated": "Obligatorie", "severity_security": "Securitate", - "title": "Actualizări aplicație" + "title": "Actualizări aplicație", + "verifying": "Se verifică…" } } diff --git a/locales/rn/ru.json b/locales/rn/ru.json index f8aa006a..9e88464e 100644 --- a/locales/rn/ru.json +++ b/locales/rn/ru.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.", "severity_deprecated": "Обязательное", "severity_security": "Безопасность", - "title": "Обновления приложения" + "title": "Обновления приложения", + "verifying": "Проверка…" } } diff --git a/locales/rn/sk.json b/locales/rn/sk.json index df535b33..b06666e8 100644 --- a/locales/rn/sk.json +++ b/locales/rn/sk.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Sledujte priebeh zostavenia alebo si stiahnite APK ručne.", "severity_deprecated": "Povinná", "severity_security": "Zabezpečenie", - "title": "Aktualizácie aplikácie" + "title": "Aktualizácie aplikácie", + "verifying": "Overovanie…" } } diff --git a/locales/rn/tr.json b/locales/rn/tr.json index d5c7331f..e14810c6 100644 --- a/locales/rn/tr.json +++ b/locales/rn/tr.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Derleme ilerlemesini izleyin veya APK'yı elle indirin.", "severity_deprecated": "Zorunlu", "severity_security": "Güvenlik", - "title": "Uygulama güncellemeleri" + "title": "Uygulama güncellemeleri", + "verifying": "Doğrulanıyor…" } } diff --git a/locales/rn/uk.json b/locales/rn/uk.json index 92910b68..0e0d71a3 100644 --- a/locales/rn/uk.json +++ b/locales/rn/uk.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.", "severity_deprecated": "Обов'язкове", "severity_security": "Безпека", - "title": "Оновлення застосунку" + "title": "Оновлення застосунку", + "verifying": "Перевірка…" } } diff --git a/locales/rn/zh-TW.json b/locales/rn/zh-TW.json index 37da3499..a93623de 100644 --- a/locales/rn/zh-TW.json +++ b/locales/rn/zh-TW.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "查看建置進度或手動下載 APK。", "severity_deprecated": "必要", "severity_security": "安全性", - "title": "應用程式更新" + "title": "應用程式更新", + "verifying": "正在驗證…" } } diff --git a/locales/rn/zh.json b/locales/rn/zh.json index e5c75ba5..5cdb8d6a 100644 --- a/locales/rn/zh.json +++ b/locales/rn/zh.json @@ -1168,6 +1168,7 @@ "release_page_pending_desc": "查看构建进度或手动下载 APK。", "severity_deprecated": "必需", "severity_security": "安全", - "title": "应用更新" + "title": "应用更新", + "verifying": "正在验证…" } } diff --git a/src/components/settings/UpdatesSettings.tsx b/src/components/settings/UpdatesSettings.tsx index cf6e25f5..6a36f421 100644 --- a/src/components/settings/UpdatesSettings.tsx +++ b/src/components/settings/UpdatesSettings.tsx @@ -32,6 +32,7 @@ function installButtonLabel( ): string { if (!installing) return t('updates.install', 'Install'); if (progress?.phase === 'installing') return t('updates.installing', 'Installing…'); + if (progress?.phase === 'verifying') return t('updates.verifying', 'Verifying…'); if (progress?.progress != null) { return t('updates.downloading_pct', 'Downloading {pct}%', { pct: Math.round(progress.progress * 100) }); } @@ -213,10 +214,12 @@ export function UpdatesSettings() { style={[ styles.progressFill, { + // The download is complete once it is verifying, so the + // bar stays full instead of restarting at 0. width: - installProgress.phase === 'installing' - ? '100%' - : `${Math.round((installProgress.progress ?? 0) * 100)}%`, + installProgress.phase === 'downloading' + ? `${Math.round((installProgress.progress ?? 0) * 100)}%` + : '100%', }, ]} /> @@ -224,7 +227,9 @@ export function UpdatesSettings() { {installProgress.phase === 'installing' ? t('updates.opening_installer', 'Opening installer…') - : formatProgressLabel(installProgress, t)} + : installProgress.phase === 'verifying' + ? t('updates.verifying', 'Verifying…') + : formatProgressLabel(installProgress, t)} ) : null} diff --git a/src/lib/__tests__/install-update.test.ts b/src/lib/__tests__/install-update.test.ts new file mode 100644 index 00000000..dc5908ee --- /dev/null +++ b/src/lib/__tests__/install-update.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHash } from 'node:crypto'; + +const files = new Map(); +const digest = vi.fn(); + +vi.mock('react-native', () => ({ Platform: { OS: 'android' } })); +vi.mock('expo-file-system', () => ({ + File: class { + constructor(private uri: string) {} + async bytes() { return files.get(this.uri) ?? new Uint8Array(); } + }, + Paths: { cache: 'file:///cache' }, +})); +vi.mock('expo-file-system/legacy', () => ({})); +vi.mock('expo-intent-launcher', () => ({})); +vi.mock('expo-crypto', () => ({ + CryptoDigestAlgorithm: { SHA256: 'SHA-256' }, + digest: (...args: unknown[]) => digest(...args), +})); + +import { hashFile } from '../install-update'; + +const sha256Hex = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex'); + +describe('hashFile', () => { + const apk = new TextEncoder().encode('not really an apk'); + + beforeEach(() => { + files.clear(); + files.set('file:///cache/app.apk', apk); + digest.mockReset(); + }); + + it('hashes the file natively in one call', async () => { + digest.mockImplementation(async (_alg: string, data: Uint8Array) => { + const out = createHash('sha256').update(data).digest(); + return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength); + }); + expect(await hashFile('file:///cache/app.apk')).toBe(sha256Hex(apk)); + expect(digest).toHaveBeenCalledTimes(1); + expect(digest).toHaveBeenCalledWith('SHA-256', apk); + }); + + it('falls back to the JS hasher when the native digest fails', async () => { + digest.mockRejectedValue(new Error('unavailable')); + expect(await hashFile('file:///cache/app.apk')).toBe(sha256Hex(apk)); + }); +}); diff --git a/src/lib/install-update.ts b/src/lib/install-update.ts index f5e4136b..20582545 100644 --- a/src/lib/install-update.ts +++ b/src/lib/install-update.ts @@ -2,20 +2,16 @@ import { Platform } from 'react-native'; import { File, Paths } from 'expo-file-system'; import * as FileSystemLegacy from 'expo-file-system/legacy'; import * as IntentLauncher from 'expo-intent-launcher'; +import * as Crypto from 'expo-crypto'; import type { ReleaseAsset } from '../api/updates'; import { Sha256 } from './sha256'; const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001; const FLAG_ACTIVITY_NEW_TASK = 0x10000000; -// Bytes per file-read chunk while computing the post-download SHA-256. Larger -// chunks are faster but balloon the JS string buffer (base64 reads on this -// platform). 256 KiB keeps memory bounded while still amortising the bridge. -const HASH_CHUNK_BYTES = 256 * 1024; - export interface InstallProgress { phase: 'downloading' | 'verifying' | 'installing'; - /** 0-1 during download; undefined for installing phase */ + /** 0-1 during download; undefined while verifying and installing */ progress?: number; /** Bytes received during current download */ bytesWritten?: number; @@ -31,34 +27,25 @@ export interface InstallParams { expectedSha256?: string | null; } -function base64ToBytes(b64: string): Uint8Array { - const bin = globalThis.atob ? globalThis.atob(b64) : atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; +function toHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, '0')).join(''); } -async function hashFile(uri: string, totalBytes: number, onProgress?: InstallProgressListener): Promise { - const hasher = new Sha256(); - let offset = 0; - while (offset < totalBytes || totalBytes === 0) { - const length = Math.min(HASH_CHUNK_BYTES, totalBytes ? totalBytes - offset : HASH_CHUNK_BYTES); - const chunkB64 = await FileSystemLegacy.readAsStringAsync(uri, { - encoding: FileSystemLegacy.EncodingType.Base64, - position: offset, - length, - }); - if (!chunkB64) break; - const bytes = base64ToBytes(chunkB64); - if (bytes.length === 0) break; +/** + * SHA-256 of a downloaded file. The bytes are read and hashed natively: + * hashing base64 chunks in JS took longer than the download itself and + * looked like a second, slower download. The JS hasher stays as a fallback + * for a build without expo-crypto's digest. + */ +export async function hashFile(uri: string): Promise { + const bytes = await new File(uri).bytes(); + try { + return toHex(await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, bytes)); + } catch { + const hasher = new Sha256(); hasher.update(bytes); - offset += bytes.length; - if (totalBytes > 0) { - onProgress?.({ phase: 'verifying', progress: offset / totalBytes }); - } - if (bytes.length < length) break; + return hasher.digestHex(); } - return hasher.digestHex(); } export async function downloadAndInstallApk( @@ -117,8 +104,8 @@ export async function downloadAndInstallApk( // signed with a different key — but that fails late (after the user sees a // confusing system dialog). Bailing here surfaces the problem cleanly. if (expectedSha256) { - onProgress?.({ phase: 'verifying', progress: 0 }); - const actual = await hashFile(result.uri, actualSize || asset.size, onProgress); + onProgress?.({ phase: 'verifying' }); + const actual = await hashFile(result.uri); if (actual.toLowerCase() !== expectedSha256.toLowerCase()) { await tryDelete(result.uri); throw new Error( diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts index d2993b14..6b4bb552 100644 --- a/src/lib/sha256.ts +++ b/src/lib/sha256.ts @@ -1,8 +1,7 @@ -// Minimal SHA-256 implementation. Used to verify APK update downloads against -// the checksum published in the GitHub release body — the JS runtime has no -// built-in `crypto.subtle.digest` and we don't currently depend on -// `expo-crypto`. Public-domain algorithm (FIPS 180-4); typical sub-second per -// MB on a modern phone, which is fine for the rare update flow. +// Minimal SHA-256 implementation. The fallback for verifying APK update +// downloads when expo-crypto's native digest is unavailable (see +// `hashFile` in install-update.ts); far too slow to be the main path. +// Public-domain algorithm (FIPS 180-4). const K = new Uint32Array([ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,