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,