mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5c491f6d7 | ||
|
|
23af324d79 | ||
|
|
761c35918f | ||
|
|
86e33b9afe |
+2
-1
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "تابع تقدّم البناء أو نزّل ملف APK يدويًا.",
|
||||
"severity_deprecated": "إلزامي",
|
||||
"severity_security": "أمني",
|
||||
"title": "تحديثات التطبيق"
|
||||
"title": "تحديثات التطبيق",
|
||||
"verifying": "جارٍ التحقق…"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "روند ساخت را دنبال کنید یا APK را دستی دانلود کنید.",
|
||||
"severity_deprecated": "الزامی",
|
||||
"severity_security": "امنیتی",
|
||||
"title": "بهروزرسانیهای برنامه"
|
||||
"title": "بهروزرسانیهای برنامه",
|
||||
"verifying": "در حال بررسی…"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "עקוב אחר התקדמות הבנייה או הורד את ה-APK ידנית.",
|
||||
"severity_deprecated": "חובה",
|
||||
"severity_security": "אבטחה",
|
||||
"title": "עדכוני אפליקציה"
|
||||
"title": "עדכוני אפליקציה",
|
||||
"verifying": "מאמת…"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "ビルドの進行状況を確認するか、APK を手動でダウンロードします。",
|
||||
"severity_deprecated": "必須",
|
||||
"severity_security": "セキュリティ",
|
||||
"title": "アプリのアップデート"
|
||||
"title": "アプリのアップデート",
|
||||
"verifying": "検証中…"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "빌드 진행 상황을 확인하거나 APK를 직접 다운로드해요.",
|
||||
"severity_deprecated": "필수",
|
||||
"severity_security": "보안",
|
||||
"title": "앱 업데이트"
|
||||
"title": "앱 업데이트",
|
||||
"verifying": "확인 중…"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "Бүтээлтийн явцыг хянах эсвэл APK-г гараар татаж авах.",
|
||||
"severity_deprecated": "Заавал",
|
||||
"severity_security": "Аюулгүй байдал",
|
||||
"title": "Аппын шинэчлэл"
|
||||
"title": "Аппын шинэчлэл",
|
||||
"verifying": "Шалгаж байна…"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "Следите за ходом сборки или скачайте APK вручную.",
|
||||
"severity_deprecated": "Обязательное",
|
||||
"severity_security": "Безопасность",
|
||||
"title": "Обновления приложения"
|
||||
"title": "Обновления приложения",
|
||||
"verifying": "Проверка…"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "Стежте за перебігом збирання або завантажте APK вручну.",
|
||||
"severity_deprecated": "Обов'язкове",
|
||||
"severity_security": "Безпека",
|
||||
"title": "Оновлення застосунку"
|
||||
"title": "Оновлення застосунку",
|
||||
"verifying": "Перевірка…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "查看建置進度或手動下載 APK。",
|
||||
"severity_deprecated": "必要",
|
||||
"severity_security": "安全性",
|
||||
"title": "應用程式更新"
|
||||
"title": "應用程式更新",
|
||||
"verifying": "正在驗證…"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1168,6 +1168,7 @@
|
||||
"release_page_pending_desc": "查看构建进度或手动下载 APK。",
|
||||
"severity_deprecated": "必需",
|
||||
"severity_security": "安全",
|
||||
"title": "应用更新"
|
||||
"title": "应用更新",
|
||||
"verifying": "正在验证…"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.70",
|
||||
"version": "0.1.72",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user