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'],
},
});