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,