fix: verify app updates without a second slow progress bar

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.
This commit is contained in:
Linus Rath
2026-10-02 15:02:10 +02:00
parent 761c35918f
commit 23af324d79
31 changed files with 135 additions and 68 deletions
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
"severity_deprecated": "إلزامي",
"severity_security": "أمني",
"title": "تحديثات التطبيق"
"title": "تحديثات التطبيق",
"verifying": "جارٍ التحقق…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -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í…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
"severity_deprecated": "الزامی",
"severity_security": "امنیتی",
"title": "به‌روزرسانی‌های برنامه"
"title": "به‌روزرسانی‌های برنامه",
"verifying": "در حال بررسی…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
"severity_deprecated": "חובה",
"severity_security": "אבטחה",
"title": "עדכוני אפליקציה"
"title": "עדכוני אפליקציה",
"verifying": "מאמת…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
"severity_deprecated": "必須",
"severity_security": "セキュリティ",
"title": "アプリのアップデート"
"title": "アプリのアップデート",
"verifying": "検証中…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
"severity_deprecated": "필수",
"severity_security": "보안",
"title": "앱 업데이트"
"title": "앱 업데이트",
"verifying": "확인 중…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
"severity_deprecated": "Заавал",
"severity_security": "Аюулгүй байдал",
"title": "Аппын шинэчлэл"
"title": "Аппын шинэчлэл",
"verifying": "Шалгаж байна…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -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ă…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
"severity_deprecated": "Обязательное",
"severity_security": "Безопасность",
"title": "Обновления приложения"
"title": "Обновления приложения",
"verifying": "Проверка…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -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…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
"severity_deprecated": "Обов'язкове",
"severity_security": "Безпека",
"title": "Оновлення застосунку"
"title": "Оновлення застосунку",
"verifying": "Перевірка…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
"severity_deprecated": "必要",
"severity_security": "安全性",
"title": "應用程式更新"
"title": "應用程式更新",
"verifying": "正在驗證…"
}
}
+2 -1
View File
@@ -1168,6 +1168,7 @@
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
"severity_deprecated": "必需",
"severity_security": "安全",
"title": "应用更新"
"title": "应用更新",
"verifying": "正在验证…"
}
}
+9 -4
View File
@@ -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() {
<Text style={styles.progressLabel}>
{installProgress.phase === 'installing'
? t('updates.opening_installer', 'Opening installer…')
: formatProgressLabel(installProgress, t)}
: installProgress.phase === 'verifying'
? t('updates.verifying', 'Verifying…')
: formatProgressLabel(installProgress, t)}
</Text>
</View>
) : null}
+49
View File
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHash } from 'node:crypto';
const files = new Map<string, Uint8Array>();
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));
});
});
+19 -32
View File
@@ -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<string> {
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<string> {
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(
+4 -5
View File
@@ -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,