diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0ba75cb2..9c42ac7f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,11 +2,9 @@ - - - + + - diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index 210b3a08..01def369 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -148,7 +148,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) conn.readTimeout = 3000 conn.instanceFollowRedirects = true conn.setRequestProperty("User-Agent", "bulwark-mobile") - conn.inputStream.use { BitmapFactory.decodeStream(it) } + // Cap the response to MAX_FAVICON_BYTES so a hostile / misconfigured + // server can't OOM the headless task with a multi-megabyte image. + // We also sample down to ~256x256 since it'll only render at 192px. + val stream = conn.inputStream + val bytes = stream.use { input -> + val limit = MAX_FAVICON_BYTES + val buf = ByteArrayOutputStream() + val chunk = ByteArray(8 * 1024) + var total = 0 + while (true) { + val n = input.read(chunk) + if (n <= 0) break + total += n + if (total > limit) return@use null + buf.write(chunk, 0, n) + } + buf.toByteArray() + } ?: return null + val opts = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts) + var sample = 1 + while ((opts.outWidth / sample) > 384 || (opts.outHeight / sample) > 384) { + sample *= 2 + } + val decodeOpts = BitmapFactory.Options().apply { + inSampleSize = sample + } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOpts) } catch (_: Exception) { null } @@ -167,6 +196,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext) fun removeListeners(count: Int) {} companion object { + // 1 MiB is plenty for a favicon; anything bigger is a sign of trouble. + private const val MAX_FAVICON_BYTES = 1 * 1024 * 1024 + @Volatile private var currentInstance: BulwarkFcmModule? = null fun emit(eventName: String, params: WritableMap?) { diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt index 4efc4372..13828893 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/NotificationTapStore.kt @@ -17,11 +17,23 @@ object NotificationTapStore { return value } + // The activity is exported (required by the LAUNCHER intent filter), so + // any third-party app on the device can craft a starting intent that + // includes these extras. Reject anything that doesn't look like the IDs + // our notification builder writes — preventing other apps from + // navigating us to a forged email/thread. + private val ID_PATTERN = Regex("^[A-Za-z0-9_-]{1,128}$") + fun captureFromIntent(intent: Intent?): TapPayload? { val extras = intent?.extras ?: return null - val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null - val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null - val subject = extras.getString(EXTRA_SUBJECT) + val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) } + ?: return null + val threadId = extras.getString(EXTRA_THREAD_ID)?.takeIf { ID_PATTERN.matches(it) } + ?: return null + // Subject is human-readable text and may legitimately contain anything; + // cap its length so a hostile launcher can't ship a 1MB string into + // the navigation payload. + val subject = extras.getString(EXTRA_SUBJECT)?.take(512) val payload = TapPayload(emailId, threadId, subject) pending = payload // Clear so a subsequent activity lifecycle event doesn't replay this. diff --git a/package.json b/package.json index 7935b8c1..21e34252 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "web": "expo start --web", "test": "vitest run", "test:watch": "vitest", + "typecheck": "tsc --noEmit", "bump": "node scripts/bump-version.mjs", "sync-locales": "node scripts/sync-locales.mjs" }, diff --git a/src/api/__tests__/calendar.test.ts b/src/api/__tests__/calendar.test.ts index 4e0f0bcb..4655b51e 100644 --- a/src/api/__tests__/calendar.test.ts +++ b/src/api/__tests__/calendar.test.ts @@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn(), + getMaxObjectsInGet: () => 500, }, })); @@ -41,7 +42,10 @@ describe('calendar operations', () => { }); describe('queryEvents', () => { - it('should query events within date range', async () => { + it('should send a CalendarEvent/query without server-side filter', async () => { + // Stalwart rejects inCalendars/after/before, so we fetch unfiltered and + // filter client-side. Asserting on the absence of `filter` keeps that + // contract pinned. mockRequest.mockResolvedValue({ methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']], }); @@ -50,12 +54,9 @@ describe('calendar operations', () => { expect(result).toEqual(['ev1', 'ev2']); const call = mockRequest.mock.calls[0][0][0]; - expect(call[1].filter).toEqual({ - inCalendars: ['cal-1'], - after: '2026-03-01T00:00:00Z', - before: '2026-03-31T23:59:59Z', - types: ['Event'], - }); + expect(call[0]).toBe('CalendarEvent/query'); + expect(call[1].filter).toBeUndefined(); + expect(call[1].accountId).toBe('acc-1'); }); }); diff --git a/src/api/__tests__/contacts.test.ts b/src/api/__tests__/contacts.test.ts index c496f903..e159700e 100644 --- a/src/api/__tests__/contacts.test.ts +++ b/src/api/__tests__/contacts.test.ts @@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn(), + getMaxObjectsInGet: () => 500, }, })); @@ -50,14 +51,14 @@ describe('contacts operations', () => { expect(result).toEqual(['c1', 'c2']); }); - it('should use default empty filter', async () => { + it('should omit filter when none provided', async () => { mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/query', { ids: [] }, '0']], }); await queryContacts(); const call = mockRequest.mock.calls[0][0][0]; - expect(call[1].filter).toEqual({}); + expect(call[1].filter).toBeUndefined(); }); }); diff --git a/src/api/__tests__/email.test.ts b/src/api/__tests__/email.test.ts index 19b62885..e12dfd0e 100644 --- a/src/api/__tests__/email.test.ts +++ b/src/api/__tests__/email.test.ts @@ -197,7 +197,7 @@ describe('email operations', () => { expect(result).toEqual(['e1']); const call = mockRequest.mock.calls[0][0][0]; - expect(call[1].filter).toEqual({ text: 'test query' }); + expect(call[1].filter).toEqual({ text: 'test* query*' }); }); it('should include mailbox filter if specified', async () => { @@ -208,7 +208,7 @@ describe('email operations', () => { await searchEmails('query', 'mb-1'); const call = mockRequest.mock.calls[0][0][0]; - expect(call[1].filter).toEqual({ text: 'query', inMailbox: 'mb-1' }); + expect(call[1].filter).toEqual({ text: 'query*', inMailbox: 'mb-1' }); }); }); diff --git a/src/api/push.ts b/src/api/push.ts index 29f9249d..8b76ecfe 100644 --- a/src/api/push.ts +++ b/src/api/push.ts @@ -201,13 +201,35 @@ export function startPolling( return () => clearInterval(timer); } +export interface StartPushOptions { + onStateChange: StateChangeHandler; + onError?: (error: Error) => void; +} + /** - * Start real-time updates, preferring SSE with polling fallback. + * Start real-time updates, preferring SSE with polling fallback. The `_client` + * argument is unused today (the underlying event source uses the singleton + * `jmapClient`) but is part of the call signature so the host App can pass + * the active client reference and trigger React re-runs on account switch. */ export function startPushUpdates( - onStateChange: StateChangeHandler, + _client: unknown, + opts: StartPushOptions, ): () => void { - const cleanup = connectEventSource(onStateChange); - if (cleanup) return cleanup; - return startPolling(onStateChange); + const onError = opts.onError; + const safeOnStateChange: StateChangeHandler = (change) => { + try { + opts.onStateChange(change); + } catch (err) { + if (onError) onError(err instanceof Error ? err : new Error(String(err))); + } + }; + try { + const cleanup = connectEventSource(safeOnStateChange); + if (cleanup) return cleanup; + return startPolling(safeOnStateChange); + } catch (err) { + if (onError) onError(err instanceof Error ? err : new Error(String(err))); + return () => undefined; + } } diff --git a/src/api/updates.ts b/src/api/updates.ts index c6eaea9e..6f08a8cf 100644 --- a/src/api/updates.ts +++ b/src/api/updates.ts @@ -13,6 +13,45 @@ export interface LatestRelease { publishedAt: string; body: string; apkAsset: ReleaseAsset | null; + // Lower-case hex SHA-256 of the APK, parsed from a `*.apk.sha256` companion + // asset or from the release body. Null when the release didn't publish one; + // the installer falls back to relying solely on Android's package signing + // check in that case (still strong, but less defense-in-depth). + apkSha256: string | null; +} + +const SHA256_RE = /\b([a-f0-9]{64})\b/i; + +function extractSha256(body: string, apkName: string | undefined): string | null { + if (!body) return null; + // Prefer a line that explicitly references the APK so we don't pick up the + // hash of an unrelated asset. + const lines = body.split(/\r?\n/); + if (apkName) { + for (const line of lines) { + if (line.includes(apkName)) { + const m = SHA256_RE.exec(line); + if (m) return m[1].toLowerCase(); + } + } + } + // Fallback: a single SHA256 anywhere in the body — common when the release + // notes just list one checksum. + const m = SHA256_RE.exec(body); + return m ? m[1].toLowerCase() : null; +} + +async function fetchCompanionSha256(asset: ReleaseAsset | undefined): Promise { + if (!asset) return null; + try { + const res = await fetch(asset.browser_download_url); + if (!res.ok) return null; + const text = await res.text(); + const m = SHA256_RE.exec(text); + return m ? m[1].toLowerCase() : null; + } catch { + return null; + } } export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise { @@ -32,6 +71,14 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise a.name.endsWith('.apk')) ?? null; + const sha256Asset = apkAsset + ? json.assets.find( + (a) => a.name === `${apkAsset.name}.sha256` || a.name === `${apkAsset.name}.SHA256`, + ) + : undefined; + const apkSha256 = + (await fetchCompanionSha256(sha256Asset)) ?? + extractSha256(json.body || '', apkAsset?.name); return { tag: json.tag_name, name: json.name || json.tag_name, @@ -39,5 +86,6 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise /g, '>'); - var safeUrl = url.replace(/"/g, '"'); + var safeUrl = trimmed + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); var html = '' + safe + ''; try { document.execCommand('insertHTML', false, html); } catch (e) {} } @@ -409,7 +419,7 @@ const RichTextEditor = React.forwardRef(function Ri (function Ri style={styles.webview} containerStyle={styles.webviewContainer} onShouldStartLoadWithRequest={(request) => { - // Allow only the initial about:blank doc + data URIs we emit ourselves. + // Allow only the initial about:blank doc + data URIs we emit + // ourselves. Reject `about:*` (e.g. about:srcdoc) and every other + // scheme so a paste-injected anchor can't navigate the editor. const url = request.url; - return !url || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:'); + return !url || url === 'about:blank' || url.startsWith('data:'); }} /> diff --git a/src/lib/email-html.ts b/src/lib/email-html.ts index bd1d8140..bbe99dbe 100644 Binary files a/src/lib/email-html.ts and b/src/lib/email-html.ts differ diff --git a/src/lib/install-update.ts b/src/lib/install-update.ts index 148d5355..f5e4136b 100644 --- a/src/lib/install-update.ts +++ b/src/lib/install-update.ts @@ -3,12 +3,18 @@ import { File, Paths } from 'expo-file-system'; import * as FileSystemLegacy from 'expo-file-system/legacy'; import * as IntentLauncher from 'expo-intent-launcher'; 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' | 'installing'; + phase: 'downloading' | 'verifying' | 'installing'; /** 0-1 during download; undefined for installing phase */ progress?: number; /** Bytes received during current download */ @@ -19,10 +25,51 @@ export interface InstallProgress { export type InstallProgressListener = (p: InstallProgress) => void; +export interface InstallParams { + asset: ReleaseAsset; + /** Optional lower-case hex SHA-256. When present, mismatched downloads abort. */ + 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; +} + +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; + hasher.update(bytes); + offset += bytes.length; + if (totalBytes > 0) { + onProgress?.({ phase: 'verifying', progress: offset / totalBytes }); + } + if (bytes.length < length) break; + } + return hasher.digestHex(); +} + export async function downloadAndInstallApk( - asset: ReleaseAsset, + params: InstallParams | ReleaseAsset, onProgress?: InstallProgressListener, ): Promise { + // Backwards-compat: callers used to pass the asset directly. Promote to the + // params shape so the verification path is opt-in by passing a checksum. + const { asset, expectedSha256 }: InstallParams = + 'asset' in params ? params : { asset: params, expectedSha256: null }; + if (Platform.OS !== 'android') { throw new Error('APK install is only supported on Android'); } @@ -53,6 +100,33 @@ export async function downloadAndInstallApk( throw new Error('APK download failed'); } + // Size sanity check. GitHub's `assets[].size` is signed by their TLS cert, + // so a byte-count mismatch is a strong signal the file was truncated / + // tampered with in transport. Cheap to compute, fail-fast. + const info = await FileSystemLegacy.getInfoAsync(result.uri); + const actualSize = (info as { size?: number }).size ?? 0; + if (asset.size > 0 && actualSize !== asset.size) { + await tryDelete(result.uri); + throw new Error( + `APK size mismatch: expected ${asset.size} bytes, got ${actualSize}`, + ); + } + + // SHA-256 verification when the release publishes one. Without it, we still + // get Android's package-signer check at install time — which rejects an APK + // 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); + if (actual.toLowerCase() !== expectedSha256.toLowerCase()) { + await tryDelete(result.uri); + throw new Error( + `APK checksum mismatch: expected ${expectedSha256}, got ${actual}`, + ); + } + } + onProgress?.({ phase: 'installing' }); const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri); @@ -64,6 +138,14 @@ export async function downloadAndInstallApk( }); } +async function tryDelete(uri: string): Promise { + try { + await FileSystemLegacy.deleteAsync(uri, { idempotent: true }); + } catch { + /* best effort */ + } +} + /** * Open the system "Install unknown apps" settings page for this app, so the * user can grant permission to install APKs from the package installer. diff --git a/src/lib/sha256.ts b/src/lib/sha256.ts new file mode 100644 index 00000000..d2993b14 --- /dev/null +++ b/src/lib/sha256.ts @@ -0,0 +1,131 @@ +// 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. + +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +function rotr(x: number, n: number): number { + return (x >>> n) | (x << (32 - n)); +} + +export class Sha256 { + private h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + ]); + private buffer = new Uint8Array(64); + private bufferLen = 0; + private byteCount = 0; + + update(bytes: Uint8Array): void { + let i = 0; + this.byteCount += bytes.length; + while (i < bytes.length) { + const room = 64 - this.bufferLen; + const chunk = Math.min(room, bytes.length - i); + this.buffer.set(bytes.subarray(i, i + chunk), this.bufferLen); + this.bufferLen += chunk; + i += chunk; + if (this.bufferLen === 64) { + this.process(this.buffer); + this.bufferLen = 0; + } + } + } + + digestHex(): string { + // Final block: append 0x80, pad with zeros, then 8-byte big-endian length. + const totalBits = this.byteCount * 8; + this.buffer[this.bufferLen++] = 0x80; + if (this.bufferLen > 56) { + while (this.bufferLen < 64) this.buffer[this.bufferLen++] = 0; + this.process(this.buffer); + this.bufferLen = 0; + } + while (this.bufferLen < 56) this.buffer[this.bufferLen++] = 0; + // 64-bit length, big-endian. We can't represent >2^53 - JS bitops are + // 32-bit, so split into hi/lo words. + const hi = Math.floor(totalBits / 0x100000000); + const lo = totalBits >>> 0; + this.buffer[56] = (hi >>> 24) & 0xff; + this.buffer[57] = (hi >>> 16) & 0xff; + this.buffer[58] = (hi >>> 8) & 0xff; + this.buffer[59] = hi & 0xff; + this.buffer[60] = (lo >>> 24) & 0xff; + this.buffer[61] = (lo >>> 16) & 0xff; + this.buffer[62] = (lo >>> 8) & 0xff; + this.buffer[63] = lo & 0xff; + this.process(this.buffer); + + let out = ''; + for (let i = 0; i < 8; i++) { + out += this.h[i].toString(16).padStart(8, '0'); + } + return out; + } + + private process(block: Uint8Array): void { + const w = new Uint32Array(64); + for (let i = 0; i < 16; i++) { + w[i] = + (block[i * 4] << 24) | + (block[i * 4 + 1] << 16) | + (block[i * 4 + 2] << 8) | + block[i * 4 + 3]; + } + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; + } + + let a = this.h[0], b = this.h[1], c = this.h[2], d = this.h[3]; + let e = this.h[4], f = this.h[5], g = this.h[6], hh = this.h[7]; + + for (let i = 0; i < 64; i++) { + const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const ch = (e & f) ^ (~e & g); + const temp1 = (hh + S1 + ch + K[i] + w[i]) | 0; + const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const mj = (a & b) ^ (a & c) ^ (b & c); + const temp2 = (S0 + mj) | 0; + hh = g; + g = f; + f = e; + e = (d + temp1) | 0; + d = c; + c = b; + b = a; + a = (temp1 + temp2) | 0; + } + + this.h[0] = (this.h[0] + a) | 0; + this.h[1] = (this.h[1] + b) | 0; + this.h[2] = (this.h[2] + c) | 0; + this.h[3] = (this.h[3] + d) | 0; + this.h[4] = (this.h[4] + e) | 0; + this.h[5] = (this.h[5] + f) | 0; + this.h[6] = (this.h[6] + g) | 0; + this.h[7] = (this.h[7] + hh) | 0; + } +} + +export function sha256Hex(bytes: Uint8Array): string { + const h = new Sha256(); + h.update(bytes); + return h.digestHex(); +} diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index b79bf681..e5b414a4 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -1,12 +1,15 @@ import React from 'react'; import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import Constants from 'expo-constants'; import { Eye, EyeOff, X } from 'lucide-react-native'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { Button, Input } from '../components'; import { useAuthStore } from '../stores/auth-store'; +const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0'; + interface LoginScreenProps { onLogin?: () => void; isAddMode?: boolean; @@ -156,7 +159,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree {/* Footer */} - Bulwark Mobile v0.0.1 + Bulwark Mobile v{APP_VERSION} Privacy Policy diff --git a/src/stores/__tests__/auth-store.test.ts b/src/stores/__tests__/auth-store.test.ts index a4646ace..5c8006f3 100644 --- a/src/stores/__tests__/auth-store.test.ts +++ b/src/stores/__tests__/auth-store.test.ts @@ -1,42 +1,62 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('expo-secure-store', () => ({ - setItemAsync: vi.fn(), - getItemAsync: vi.fn(), - deleteItemAsync: vi.fn(), -})); - vi.mock('../../api/jmap-client', () => ({ jmapClient: { connect: vi.fn(), logout: vi.fn(), restoreSession: vi.fn(), + loadAccount: vi.fn(), + consumeLegacyCredentials: vi.fn(async () => null), + clearAccountCredentials: vi.fn(async () => undefined), + clearAllCredentials: vi.fn(async () => undefined), + reset: vi.fn(), accountId: 'acc-1', currentSession: { apiUrl: 'https://mail.example.com/jmap/' }, + username: 'user', + serverUrl: 'https://mail.example.com', }, AuthenticationError: class AuthenticationError extends Error { constructor(msg: string) { super(msg); this.name = 'AuthenticationError'; } }, + NetworkError: class NetworkError extends Error { + constructor(msg: string) { super(msg); this.name = 'NetworkError'; } + }, +})); + +vi.mock('../../lib/push-notifications', () => ({ + teardownPushNotifications: vi.fn(async () => undefined), })); import { jmapClient } from '../../api/jmap-client'; import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; const mockConnect = jmapClient.connect as ReturnType; const mockLogout = jmapClient.logout as ReturnType; -const mockRestore = jmapClient.restoreSession as ReturnType; +const mockLoadAccount = jmapClient.loadAccount as ReturnType; + +function resetAccountStore(): void { + useAccountStore.setState({ + accounts: [], + activeAccountId: null, + defaultAccountId: null, + }); +} beforeEach(() => { vi.clearAllMocks(); - // Reset store state + resetAccountStore(); useAuthStore.setState({ isAuthenticated: false, isLoading: false, + hasRestoredSession: false, error: null, serverUrl: null, username: null, session: null, accountId: null, + activeAccountId: null, + client: null, }); }); @@ -83,7 +103,7 @@ describe('auth-store', () => { }); describe('logout', () => { - it('should reset all state', async () => { + it('should reset all state when no other accounts remain', async () => { useAuthStore.setState({ isAuthenticated: true, serverUrl: 'x', username: 'y' }); mockLogout.mockResolvedValue(undefined); @@ -97,23 +117,40 @@ describe('auth-store', () => { }); describe('restoreSession', () => { - it('should restore on success', async () => { - mockRestore.mockResolvedValue(true); + it('should return false when there is no registered account', async () => { + const restored = await useAuthStore.getState().restoreSession(); + + expect(restored).toBe(false); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(useAuthStore.getState().hasRestoredSession).toBe(true); + }); + + it('should restore when loadAccount succeeds for the active account', async () => { + useAccountStore.setState({ + accounts: [ + { + id: 'acc-1', + serverUrl: 'https://mail.example.com', + username: 'user', + displayName: 'user', + email: 'user', + avatarColor: '#000', + lastLoginAt: 0, + isConnected: false, + hasError: false, + isDefault: true, + }, + ], + activeAccountId: 'acc-1', + defaultAccountId: 'acc-1', + }); + mockLoadAccount.mockResolvedValue(true); const restored = await useAuthStore.getState().restoreSession(); expect(restored).toBe(true); expect(useAuthStore.getState().isAuthenticated).toBe(true); }); - - it('should return false when no session to restore', async () => { - mockRestore.mockResolvedValue(false); - - const restored = await useAuthStore.getState().restoreSession(); - - expect(restored).toBe(false); - expect(useAuthStore.getState().isAuthenticated).toBe(false); - }); }); describe('clearError', () => { diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index a2762e4a..4a81b296 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -1,7 +1,8 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { Email, Mailbox } from '../api/types'; +import type { Email, Mailbox, StateChange } from '../api/types'; +import { jmapClient } from '../api/jmap-client'; import { getMailboxes as fetchMailboxes, queryEmails, @@ -57,6 +58,7 @@ export interface EmailState { selectMailbox: (mailboxId: string) => Promise; loadMoreEmails: () => Promise; refreshEmails: () => Promise; + handleStateChange: (change: StateChange) => Promise; getEmailDetail: (id: string) => Promise; markRead: (emailId: string) => Promise; markUnread: (emailId: string) => Promise; @@ -250,6 +252,24 @@ export const useEmailStore = create()( } }, + handleStateChange: async (change) => { + if (!jmapClient.currentSession) return; + const accountId = jmapClient.accountId; + const accountChanges = change.changed?.[accountId]; + if (!accountChanges) return; + + const mailboxChanged = 'Mailbox' in accountChanges; + const emailChanged = 'Email' in accountChanges || 'EmailDelivery' in accountChanges; + if (!mailboxChanged && !emailChanged) return; + + if (mailboxChanged) { + await get().fetchMailboxes(); + } + if (emailChanged && get().currentMailboxId) { + await get().refreshEmails(); + } + }, + setSearchQuery: (query) => { set({ searchQuery: query }); void get().refreshEmails(); diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index c8a2e9f4..1df677ec 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -133,12 +133,11 @@ interface PersistedSettings { pluginEnabled: Record; // Offline mail cache: download recent message bodies in the background so - // they can be opened without network. Days windows the lookback; the - // attachments toggle controls whether file blobs are cached too (off by - // default — bodies-only is much smaller). + // they can be opened without network. Days windows the lookback. Attachment + // caching is intentionally not implemented yet — bodies-only is much + // smaller and covers the "open recent mail offline" UX on its own. offlineCacheEnabled: boolean; offlineCacheDays: number; - offlineCacheIncludeAttachments: boolean; } const DEFAULT_PERSISTED: PersistedSettings = { @@ -218,7 +217,6 @@ const DEFAULT_PERSISTED: PersistedSettings = { offlineCacheEnabled: false, offlineCacheDays: 7, - offlineCacheIncludeAttachments: false, }; export interface SettingsState extends PersistedSettings { diff --git a/src/stores/updates-store.ts b/src/stores/updates-store.ts index f7d613a5..8df649ca 100644 --- a/src/stores/updates-store.ts +++ b/src/stores/updates-store.ts @@ -108,9 +108,15 @@ export const useUpdatesStore = create((set, get) => ({ if (s.installing || !s.cachedLatest?.apkAsset) return; set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } }); try { - await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => { - set({ installProgress: p }); - }); + await downloadAndInstallApk( + { + asset: s.cachedLatest.apkAsset, + expectedSha256: s.cachedLatest.apkSha256, + }, + (p) => { + set({ installProgress: p }); + }, + ); set({ installing: false, installProgress: null }); } catch (err) { set({ diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 00000000..f60eb572 --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,66 @@ +// Vitest setup. Two pieces of plumbing the unit tests need: +// +// 1. AsyncStorage: the Zustand `persist` middleware used by several stores +// reaches in during `set()` calls, which on a `node` environment crashes +// inside the RN AsyncStorage shim (no `window` global). Swap in an +// in-memory implementation. +// 2. react-native: the bundled entry point is Flow-typed and rolldown can't +// parse it. Stub the few surfaces used at module-load time so any store +// that transitively imports `react-native` (via push / client-cert +// bridges) loads cleanly. Tests that actually exercise native modules +// re-mock the relevant module locally. + +import { vi } from 'vitest'; + +const memory = new Map(); + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => memory.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + memory.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + memory.delete(key); + }), + clear: vi.fn(async () => { + memory.clear(); + }), + getAllKeys: vi.fn(async () => Array.from(memory.keys())), + multiGet: vi.fn(async (keys: string[]) => + keys.map((k) => [k, memory.get(k) ?? null] as [string, string | null]), + ), + multiSet: vi.fn(async (pairs: [string, string][]) => { + for (const [k, v] of pairs) memory.set(k, v); + }), + multiRemove: vi.fn(async (keys: string[]) => { + for (const k of keys) memory.delete(k); + }), + }, +})); + +vi.mock('react-native', () => { + class NativeEventEmitter { + addListener() { + return { remove: () => undefined }; + } + removeAllListeners() {} + } + return { + Platform: { OS: 'android', Version: 33, select: (spec: { default?: T; android?: T; ios?: T }) => spec.android ?? spec.default ?? spec.ios }, + NativeModules: {}, + NativeEventEmitter, + PermissionsAndroid: { + RESULTS: { GRANTED: 'granted', DENIED: 'denied', NEVER_ASK_AGAIN: 'never_ask_again' }, + request: async () => 'granted', + }, + Linking: { openURL: async () => undefined }, + Appearance: { getColorScheme: () => 'dark', addChangeListener: () => ({ remove: () => undefined }) }, + }; +}); + +vi.mock('expo-secure-store', () => ({ + setItemAsync: vi.fn(async () => undefined), + getItemAsync: vi.fn(async () => null), + deleteItemAsync: vi.fn(async () => undefined), +})); diff --git a/vitest.config.ts b/vitest.config.ts index d74d4049..1604be71 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/__tests__/**/*.test.ts'], + setupFiles: ['src/test-setup.ts'], }, });