Compare commits

..
4 Commits
Author SHA1 Message Date
Linus Rath f5c491f6d7 chore: update version number to 0.1.72 2026-10-02 15:02:12 +02:00
Linus Rath 23af324d79 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.
2026-10-02 15:02:10 +02:00
Linus Rath 761c35918f chore: update version number to 0.1.71 2026-10-02 14:09:36 +02:00
Linus Rath 86e33b9afe fix: sync trusted senders to the address book by default
The "Sync with address book" toggle under Content & Senders was off until
the user turned it on, so senders trusted on the phone stayed in the
local allow-list and never reached the "Trusted Senders" address book the
other devices read. A setting the user never chose now counts as on;
only an explicit opt-out, or a server without contacts, turns it off.
2026-10-02 14:09:34 +02:00
40 changed files with 190 additions and 95 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.70
0.1.72
+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": "正在验证…"
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.70",
"version": "0.1.72",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.70",
"version": "0.1.72",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.70",
"version": "0.1.72",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+11 -9
View File
@@ -12,7 +12,8 @@ import { bodyDocument } from '../lib/email-body-document';
import { estimateBodyHeight, lastBodyHeight, rememberBodyHeight } from '../lib/body-heights';
import { parseMailtoUrl } from '../lib/unsubscribe';
import { fetchInlineImageDataUri } from '../lib/email-export';
import { isSenderContentTrusted } from '../lib/trusted-senders';
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
import { useHasContacts } from '../lib/capabilities';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
@@ -527,6 +528,8 @@ export default function EmailBodyView({
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const hasContacts = useHasContacts();
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
@@ -540,8 +543,8 @@ export default function EmailBodyView({
// entries (synced across devices) feed the external-content trust check. We
// do not create the book here — it is created lazily when a sender is trusted.
React.useEffect(() => {
if (trustedSendersAddressBook && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [trustedSendersAddressBook, trustedSendersLoaded, loadTrustedSendersBook]);
if (syncTrustedSenders && !trustedSendersLoaded) void loadTrustedSendersBook(false);
}, [syncTrustedSenders, trustedSendersLoaded, loadTrustedSendersBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark; the
@@ -561,7 +564,7 @@ export default function EmailBodyView({
const text = picked.text;
const trusted = isSenderContentTrusted(senderEmail, {
isLocallyTrusted: isSenderTrusted,
syncEnabled: trustedSendersAddressBook,
syncEnabled: syncTrustedSenders,
trustedBookEmails: trustedSenderEmails,
});
@@ -721,12 +724,11 @@ export default function EmailBodyView({
const onLoadImages = () => setAllowOnce(true);
const onTrustSender = () => {
if (senderEmail) {
// Keep the local allow-list for instant effect. The server-side "Trusted
// Senders" address book (synced across devices) is only written when
// the user opted into it - otherwise a book would silently appear in
// their contacts (matches the webmail).
// Keep the local allow-list for instant effect, and file the sender in
// the server-side "Trusted Senders" address book (synced across
// devices) unless the user turned that off.
addTrustedSender(senderEmail);
if (trustedSendersAddressBook) {
if (syncTrustedSenders) {
void addToTrustedSendersBook(senderEmail).catch(() => {});
}
setAllowOnce(true);
@@ -10,6 +10,7 @@ import { useColors } from '../../theme/colors';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
import { useContactsStore } from '../../stores/contacts-store';
import { useHasContacts } from '../../lib/capabilities';
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
import { useLocaleStore } from '../../stores/locale-store';
interface TrustedRow {
@@ -49,9 +50,8 @@ export function ContentSendersSettings() {
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
// Sync stays off until the user turns it on below (null = never chosen):
// opening this pane must not change which senders the viewer trusts.
const syncEnabled = !!trustedSendersAddressBook && hasContacts;
// On unless the user turned it off below (null = never chosen).
const syncEnabled = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
useEffect(() => {
if (modalOpen && syncEnabled) void loadTrustedSendersBook();
@@ -179,7 +179,7 @@ export function ContentSendersSettings() {
description={t('settings.email_behavior.trusted_senders.use_address_book_description', 'Store trusted senders in a dedicated "Trusted Senders" address book so they sync across all your devices')}
>
<ToggleSwitch
checked={!!trustedSendersAddressBook}
checked={syncEnabled}
onChange={(enabled) => {
updateSetting('trustedSendersAddressBook', enabled);
if (enabled) void loadTrustedSendersBook();
+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));
});
});
+17 -4
View File
@@ -1,11 +1,26 @@
import { describe, it, expect } from 'vitest';
import { isSenderContentTrusted } from '../trusted-senders';
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../trusted-senders';
const local = (list: string[]) => (email: string) => list.includes(email.toLowerCase());
describe('isTrustedSendersSyncOn', () => {
it('is on unless the user opted out', () => {
expect(isTrustedSendersSyncOn(null, true)).toBe(true);
expect(isTrustedSendersSyncOn(undefined, true)).toBe(true);
expect(isTrustedSendersSyncOn(true, true)).toBe(true);
expect(isTrustedSendersSyncOn(false, true)).toBe(false);
});
it('is off on a server without contacts', () => {
for (const setting of [null, true, false]) {
expect(isTrustedSendersSyncOn(setting, false)).toBe(false);
}
});
});
describe('isSenderContentTrusted', () => {
it('trusts senders on the local allow-list whatever the sync setting', () => {
for (const syncEnabled of [null, false, true]) {
for (const syncEnabled of [false, true]) {
expect(isSenderContentTrusted('Alice@Example.com', {
isLocallyTrusted: local(['alice@example.com']),
syncEnabled,
@@ -18,8 +33,6 @@ describe('isSenderContentTrusted', () => {
const opts = { isLocallyTrusted: local([]), trustedBookEmails: ['bob@example.com'] };
expect(isSenderContentTrusted('Bob@Example.com', { ...opts, syncEnabled: true })).toBe(true);
expect(isSenderContentTrusted('bob@example.com', { ...opts, syncEnabled: false })).toBe(false);
// Never chosen counts as off: the default must not widen trust.
expect(isSenderContentTrusted('bob@example.com', { ...opts, syncEnabled: null })).toBe(false);
});
it('does not trust a sender just because some other list knows them', () => {
+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,
+13 -3
View File
@@ -1,3 +1,13 @@
/**
* Whether "Sync trusted senders to address book" is in effect. Never chosen
* (null) counts as on, so trusted senders land in the dedicated "Trusted
* Senders" address book out of the box; only the user's opt-out (false), or
* a server without contacts, turns it off.
*/
export function isTrustedSendersSyncOn(setting: boolean | null | undefined, hasContacts: boolean): boolean {
return hasContacts && setting !== false;
}
/**
* Whether the viewer may load a sender's remote content (images, tracking
* pixels) without asking.
@@ -13,8 +23,8 @@ export function isSenderContentTrusted(
opts: {
/** The settings store's local allow-list check. */
isLocallyTrusted: (email: string) => boolean;
/** The "Sync trusted senders to address book" setting (null = never chosen, off). */
syncEnabled: boolean | null;
/** Whether address book sync is in effect (`isTrustedSendersSyncOn`). */
syncEnabled: boolean;
/** Lowercased addresses filed in the "Trusted Senders" book. */
trustedBookEmails: readonly string[];
},
@@ -22,5 +32,5 @@ export function isSenderContentTrusted(
const email = senderEmail?.trim();
if (!email) return false;
if (opts.isLocallyTrusted(email)) return true;
return !!opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase());
return opts.syncEnabled && opts.trustedBookEmails.includes(email.toLowerCase());
}
+4 -1
View File
@@ -33,6 +33,8 @@ import { getEmailInitials } from '../lib/avatar-utils';
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useHasContacts } from '../lib/capabilities';
import { isTrustedSendersSyncOn } from '../lib/trusted-senders';
import { useAccountStore } from '../stores/account-store';
import { useSendUndoStore } from '../stores/send-undo-store';
import { toast } from '../stores/toast-store';
@@ -477,6 +479,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const subAddressDelimiter = useSettingsStore((s) => s.subAddressDelimiter);
const preferredIdentityIds = useSettingsStore((s) => s.preferredIdentityIds);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const hasContacts = useHasContacts();
const updateSetting = useSettingsStore((s) => s.updateSetting);
const quoteLabels = React.useMemo<QuoteHeaderLabels>(() => quoteHeaderLabels(t), [t]);
@@ -2130,7 +2133,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const contacts = useContactsStore.getState();
for (const r of [...outgoing.to, ...(outgoing.cc ?? [])]) {
settings.addTrustedSender(r.email);
if (trustedSendersAddressBook) {
if (isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts)) {
contacts.addToTrustedSendersBook(r.name ? `${r.name} <${r.email}>` : r.email).catch(() => undefined);
}
}
+2 -2
View File
@@ -127,8 +127,8 @@ interface PersistedSettings {
// Privacy & content
externalContentPolicy: ExternalContentPolicy;
trustedSenders: string[];
// null = not decided yet and treated as off; only the user's toggle in
// Content & Senders turns it on, false = user opted out.
// null = not decided yet and treated as on (isTrustedSendersSyncOn);
// false = the user turned it off in Content & Senders.
trustedSendersAddressBook: boolean | null;
senderFavicons: boolean;
hideInlineImageAttachments: boolean;