mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
security: fix push SSE breakage, harden APK updates and email rendering
This commit is contained in:
@@ -2,11 +2,9 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
|||||||
@@ -148,7 +148,36 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
conn.readTimeout = 3000
|
conn.readTimeout = 3000
|
||||||
conn.instanceFollowRedirects = true
|
conn.instanceFollowRedirects = true
|
||||||
conn.setRequestProperty("User-Agent", "bulwark-mobile")
|
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) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -167,6 +196,9 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
|||||||
fun removeListeners(count: Int) {}
|
fun removeListeners(count: Int) {}
|
||||||
|
|
||||||
companion object {
|
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
|
@Volatile private var currentInstance: BulwarkFcmModule? = null
|
||||||
|
|
||||||
fun emit(eventName: String, params: WritableMap?) {
|
fun emit(eventName: String, params: WritableMap?) {
|
||||||
|
|||||||
@@ -17,11 +17,23 @@ object NotificationTapStore {
|
|||||||
return value
|
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? {
|
fun captureFromIntent(intent: Intent?): TapPayload? {
|
||||||
val extras = intent?.extras ?: return null
|
val extras = intent?.extras ?: return null
|
||||||
val emailId = extras.getString(EXTRA_EMAIL_ID) ?: return null
|
val emailId = extras.getString(EXTRA_EMAIL_ID)?.takeIf { ID_PATTERN.matches(it) }
|
||||||
val threadId = extras.getString(EXTRA_THREAD_ID) ?: return null
|
?: return null
|
||||||
val subject = extras.getString(EXTRA_SUBJECT)
|
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)
|
val payload = TapPayload(emailId, threadId, subject)
|
||||||
pending = payload
|
pending = payload
|
||||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"bump": "node scripts/bump-version.mjs",
|
"bump": "node scripts/bump-version.mjs",
|
||||||
"sync-locales": "node scripts/sync-locales.mjs"
|
"sync-locales": "node scripts/sync-locales.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
|
|||||||
jmapClient: {
|
jmapClient: {
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
request: vi.fn(),
|
request: vi.fn(),
|
||||||
|
getMaxObjectsInGet: () => 500,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -41,7 +42,10 @@ describe('calendar operations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('queryEvents', () => {
|
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({
|
mockRequest.mockResolvedValue({
|
||||||
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
|
methodResponses: [['CalendarEvent/query', { ids: ['ev1', 'ev2'] }, '0']],
|
||||||
});
|
});
|
||||||
@@ -50,12 +54,9 @@ describe('calendar operations', () => {
|
|||||||
expect(result).toEqual(['ev1', 'ev2']);
|
expect(result).toEqual(['ev1', 'ev2']);
|
||||||
|
|
||||||
const call = mockRequest.mock.calls[0][0][0];
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
expect(call[1].filter).toEqual({
|
expect(call[0]).toBe('CalendarEvent/query');
|
||||||
inCalendars: ['cal-1'],
|
expect(call[1].filter).toBeUndefined();
|
||||||
after: '2026-03-01T00:00:00Z',
|
expect(call[1].accountId).toBe('acc-1');
|
||||||
before: '2026-03-31T23:59:59Z',
|
|
||||||
types: ['Event'],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ vi.mock('../jmap-client', () => ({
|
|||||||
jmapClient: {
|
jmapClient: {
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
request: vi.fn(),
|
request: vi.fn(),
|
||||||
|
getMaxObjectsInGet: () => 500,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,14 +51,14 @@ describe('contacts operations', () => {
|
|||||||
expect(result).toEqual(['c1', 'c2']);
|
expect(result).toEqual(['c1', 'c2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default empty filter', async () => {
|
it('should omit filter when none provided', async () => {
|
||||||
mockRequest.mockResolvedValue({
|
mockRequest.mockResolvedValue({
|
||||||
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
|
methodResponses: [['ContactCard/query', { ids: [] }, '0']],
|
||||||
});
|
});
|
||||||
|
|
||||||
await queryContacts();
|
await queryContacts();
|
||||||
const call = mockRequest.mock.calls[0][0][0];
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
expect(call[1].filter).toEqual({});
|
expect(call[1].filter).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ describe('email operations', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(['e1']);
|
expect(result).toEqual(['e1']);
|
||||||
const call = mockRequest.mock.calls[0][0][0];
|
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 () => {
|
it('should include mailbox filter if specified', async () => {
|
||||||
@@ -208,7 +208,7 @@ describe('email operations', () => {
|
|||||||
await searchEmails('query', 'mb-1');
|
await searchEmails('query', 'mb-1');
|
||||||
|
|
||||||
const call = mockRequest.mock.calls[0][0][0];
|
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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+27
-5
@@ -201,13 +201,35 @@ export function startPolling(
|
|||||||
return () => clearInterval(timer);
|
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(
|
export function startPushUpdates(
|
||||||
onStateChange: StateChangeHandler,
|
_client: unknown,
|
||||||
|
opts: StartPushOptions,
|
||||||
): () => void {
|
): () => void {
|
||||||
const cleanup = connectEventSource(onStateChange);
|
const onError = opts.onError;
|
||||||
if (cleanup) return cleanup;
|
const safeOnStateChange: StateChangeHandler = (change) => {
|
||||||
return startPolling(onStateChange);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,45 @@ export interface LatestRelease {
|
|||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
body: string;
|
body: string;
|
||||||
apkAsset: ReleaseAsset | null;
|
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<string | null> {
|
||||||
|
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<LatestRelease | null> {
|
export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<LatestRelease | null> {
|
||||||
@@ -32,6 +71,14 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
|||||||
assets: ReleaseAsset[];
|
assets: ReleaseAsset[];
|
||||||
};
|
};
|
||||||
const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null;
|
const apkAsset = json.assets.find((a) => 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 {
|
return {
|
||||||
tag: json.tag_name,
|
tag: json.tag_name,
|
||||||
name: json.name || json.tag_name,
|
name: json.name || json.tag_name,
|
||||||
@@ -39,5 +86,6 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
|||||||
publishedAt: json.published_at,
|
publishedAt: json.published_at,
|
||||||
body: json.body || '',
|
body: json.body || '',
|
||||||
apkAsset,
|
apkAsset,
|
||||||
|
apkSha256,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
|||||||
<View style={[styles.webContainer, { height }]}>
|
<View style={[styles.webContainer, { height }]}>
|
||||||
<WebView
|
<WebView
|
||||||
ref={webviewRef}
|
ref={webviewRef}
|
||||||
originWhitelist={['about:*']}
|
originWhitelist={['about:blank']}
|
||||||
source={source}
|
source={source}
|
||||||
javaScriptEnabled
|
javaScriptEnabled
|
||||||
injectedJavaScript={HEIGHT_REPORTER}
|
injectedJavaScript={HEIGHT_REPORTER}
|
||||||
|
|||||||
@@ -263,20 +263,30 @@ function buildEditorHtml(opts: {
|
|||||||
},
|
},
|
||||||
insertLink: function (url, label) {
|
insertLink: function (url, label) {
|
||||||
editor.focus();
|
editor.focus();
|
||||||
|
// Reject anything that isn't a safe scheme or an absolute path, so the
|
||||||
|
// user can't accidentally (or via a malicious paste) ship a
|
||||||
|
// javascript:/data: link to their recipient.
|
||||||
|
var safeSchemeRe = /^(https?:|mailto:|tel:|sms:|\/|\?|#)/i;
|
||||||
|
var trimmed = (url || '').trim();
|
||||||
|
if (!trimmed || !safeSchemeRe.test(trimmed)) return;
|
||||||
var sel = window.getSelection();
|
var sel = window.getSelection();
|
||||||
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
var hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||||
if (hasSelection) {
|
if (hasSelection) {
|
||||||
try { document.execCommand('createLink', false, url); } catch (e) {}
|
try { document.execCommand('createLink', false, trimmed); } catch (e) {}
|
||||||
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
// execCommand doesn't set rel; tag every anchor that points at this URL.
|
||||||
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
Array.prototype.forEach.call(editor.getElementsByTagName('a'), function (a) {
|
||||||
if (a.getAttribute('href') === url) {
|
if (a.getAttribute('href') === trimmed) {
|
||||||
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
a.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
var text = label && label.trim() ? label : url;
|
var text = label && label.trim() ? label : trimmed;
|
||||||
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
var safe = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
var safeUrl = url.replace(/"/g, '"');
|
var safeUrl = trimmed
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
var html = '<a href="' + safeUrl + '" rel="noopener noreferrer nofollow">' + safe + '</a>';
|
||||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||||
}
|
}
|
||||||
@@ -409,7 +419,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
<View style={[styles.container, { height }]}>
|
<View style={[styles.container, { height }]}>
|
||||||
<WebView
|
<WebView
|
||||||
ref={webViewRef}
|
ref={webViewRef}
|
||||||
originWhitelist={['about:*']}
|
originWhitelist={['about:blank']}
|
||||||
source={{ html }}
|
source={{ html }}
|
||||||
onMessage={onMessage}
|
onMessage={onMessage}
|
||||||
javaScriptEnabled
|
javaScriptEnabled
|
||||||
@@ -433,9 +443,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
|||||||
style={styles.webview}
|
style={styles.webview}
|
||||||
containerStyle={styles.webviewContainer}
|
containerStyle={styles.webviewContainer}
|
||||||
onShouldStartLoadWithRequest={(request) => {
|
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;
|
const url = request.url;
|
||||||
return !url || url === 'about:blank' || url.startsWith('data:') || url.startsWith('about:');
|
return !url || url === 'about:blank' || url.startsWith('data:');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Binary file not shown.
@@ -3,12 +3,18 @@ import { File, Paths } from 'expo-file-system';
|
|||||||
import * as FileSystemLegacy from 'expo-file-system/legacy';
|
import * as FileSystemLegacy from 'expo-file-system/legacy';
|
||||||
import * as IntentLauncher from 'expo-intent-launcher';
|
import * as IntentLauncher from 'expo-intent-launcher';
|
||||||
import type { ReleaseAsset } from '../api/updates';
|
import type { ReleaseAsset } from '../api/updates';
|
||||||
|
import { Sha256 } from './sha256';
|
||||||
|
|
||||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||||
const FLAG_ACTIVITY_NEW_TASK = 0x10000000;
|
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 {
|
export interface InstallProgress {
|
||||||
phase: 'downloading' | 'installing';
|
phase: 'downloading' | 'verifying' | 'installing';
|
||||||
/** 0-1 during download; undefined for installing phase */
|
/** 0-1 during download; undefined for installing phase */
|
||||||
progress?: number;
|
progress?: number;
|
||||||
/** Bytes received during current download */
|
/** Bytes received during current download */
|
||||||
@@ -19,10 +25,51 @@ export interface InstallProgress {
|
|||||||
|
|
||||||
export type InstallProgressListener = (p: InstallProgress) => void;
|
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<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;
|
||||||
|
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(
|
export async function downloadAndInstallApk(
|
||||||
asset: ReleaseAsset,
|
params: InstallParams | ReleaseAsset,
|
||||||
onProgress?: InstallProgressListener,
|
onProgress?: InstallProgressListener,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// 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') {
|
if (Platform.OS !== 'android') {
|
||||||
throw new Error('APK install is only supported on 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');
|
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' });
|
onProgress?.({ phase: 'installing' });
|
||||||
|
|
||||||
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
|
const contentUri = await FileSystemLegacy.getContentUriAsync(result.uri);
|
||||||
@@ -64,6 +138,14 @@ export async function downloadAndInstallApk(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function tryDelete(uri: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await FileSystemLegacy.deleteAsync(uri, { idempotent: true });
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the system "Install unknown apps" settings page for this app, so the
|
* Open the system "Install unknown apps" settings page for this app, so the
|
||||||
* user can grant permission to install APKs from the package installer.
|
* user can grant permission to install APKs from the package installer.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
import { Eye, EyeOff, X } from 'lucide-react-native';
|
import { Eye, EyeOff, X } from 'lucide-react-native';
|
||||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
import { useColors } from '../theme/colors';
|
||||||
import { Button, Input } from '../components';
|
import { Button, Input } from '../components';
|
||||||
import { useAuthStore } from '../stores/auth-store';
|
import { useAuthStore } from '../stores/auth-store';
|
||||||
|
|
||||||
|
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||||
|
|
||||||
interface LoginScreenProps {
|
interface LoginScreenProps {
|
||||||
onLogin?: () => void;
|
onLogin?: () => void;
|
||||||
isAddMode?: boolean;
|
isAddMode?: boolean;
|
||||||
@@ -156,7 +159,7 @@ export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScree
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<Text style={styles.footerText}>Bulwark Mobile v0.0.1</Text>
|
<Text style={styles.footerText}>Bulwark Mobile v{APP_VERSION}</Text>
|
||||||
<Text style={styles.footerLink}>Privacy Policy</Text>
|
<Text style={styles.footerLink}>Privacy Policy</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,42 +1,62 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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', () => ({
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
jmapClient: {
|
jmapClient: {
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
logout: vi.fn(),
|
logout: vi.fn(),
|
||||||
restoreSession: 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',
|
accountId: 'acc-1',
|
||||||
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
|
||||||
|
username: 'user',
|
||||||
|
serverUrl: 'https://mail.example.com',
|
||||||
},
|
},
|
||||||
AuthenticationError: class AuthenticationError extends Error {
|
AuthenticationError: class AuthenticationError extends Error {
|
||||||
constructor(msg: string) { super(msg); this.name = 'AuthenticationError'; }
|
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 { jmapClient } from '../../api/jmap-client';
|
||||||
import { useAuthStore } from '../auth-store';
|
import { useAuthStore } from '../auth-store';
|
||||||
|
import { useAccountStore } from '../account-store';
|
||||||
|
|
||||||
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
|
const mockConnect = jmapClient.connect as ReturnType<typeof vi.fn>;
|
||||||
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
|
const mockLogout = jmapClient.logout as ReturnType<typeof vi.fn>;
|
||||||
const mockRestore = jmapClient.restoreSession as ReturnType<typeof vi.fn>;
|
const mockLoadAccount = jmapClient.loadAccount as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function resetAccountStore(): void {
|
||||||
|
useAccountStore.setState({
|
||||||
|
accounts: [],
|
||||||
|
activeAccountId: null,
|
||||||
|
defaultAccountId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Reset store state
|
resetAccountStore();
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
hasRestoredSession: false,
|
||||||
error: null,
|
error: null,
|
||||||
serverUrl: null,
|
serverUrl: null,
|
||||||
username: null,
|
username: null,
|
||||||
session: null,
|
session: null,
|
||||||
accountId: null,
|
accountId: null,
|
||||||
|
activeAccountId: null,
|
||||||
|
client: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +103,7 @@ describe('auth-store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('logout', () => {
|
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' });
|
useAuthStore.setState({ isAuthenticated: true, serverUrl: 'x', username: 'y' });
|
||||||
mockLogout.mockResolvedValue(undefined);
|
mockLogout.mockResolvedValue(undefined);
|
||||||
|
|
||||||
@@ -97,23 +117,40 @@ describe('auth-store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('restoreSession', () => {
|
describe('restoreSession', () => {
|
||||||
it('should restore on success', async () => {
|
it('should return false when there is no registered account', async () => {
|
||||||
mockRestore.mockResolvedValue(true);
|
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();
|
const restored = await useAuthStore.getState().restoreSession();
|
||||||
|
|
||||||
expect(restored).toBe(true);
|
expect(restored).toBe(true);
|
||||||
expect(useAuthStore.getState().isAuthenticated).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', () => {
|
describe('clearError', () => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
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 {
|
import {
|
||||||
getMailboxes as fetchMailboxes,
|
getMailboxes as fetchMailboxes,
|
||||||
queryEmails,
|
queryEmails,
|
||||||
@@ -57,6 +58,7 @@ export interface EmailState {
|
|||||||
selectMailbox: (mailboxId: string) => Promise<void>;
|
selectMailbox: (mailboxId: string) => Promise<void>;
|
||||||
loadMoreEmails: () => Promise<void>;
|
loadMoreEmails: () => Promise<void>;
|
||||||
refreshEmails: () => Promise<void>;
|
refreshEmails: () => Promise<void>;
|
||||||
|
handleStateChange: (change: StateChange) => Promise<void>;
|
||||||
getEmailDetail: (id: string) => Promise<Email>;
|
getEmailDetail: (id: string) => Promise<Email>;
|
||||||
markRead: (emailId: string) => Promise<void>;
|
markRead: (emailId: string) => Promise<void>;
|
||||||
markUnread: (emailId: string) => Promise<void>;
|
markUnread: (emailId: string) => Promise<void>;
|
||||||
@@ -250,6 +252,24 @@ export const useEmailStore = create<EmailState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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) => {
|
setSearchQuery: (query) => {
|
||||||
set({ searchQuery: query });
|
set({ searchQuery: query });
|
||||||
void get().refreshEmails();
|
void get().refreshEmails();
|
||||||
|
|||||||
@@ -133,12 +133,11 @@ interface PersistedSettings {
|
|||||||
pluginEnabled: Record<string, boolean>;
|
pluginEnabled: Record<string, boolean>;
|
||||||
|
|
||||||
// Offline mail cache: download recent message bodies in the background so
|
// Offline mail cache: download recent message bodies in the background so
|
||||||
// they can be opened without network. Days windows the lookback; the
|
// they can be opened without network. Days windows the lookback. Attachment
|
||||||
// attachments toggle controls whether file blobs are cached too (off by
|
// caching is intentionally not implemented yet — bodies-only is much
|
||||||
// default — bodies-only is much smaller).
|
// smaller and covers the "open recent mail offline" UX on its own.
|
||||||
offlineCacheEnabled: boolean;
|
offlineCacheEnabled: boolean;
|
||||||
offlineCacheDays: number;
|
offlineCacheDays: number;
|
||||||
offlineCacheIncludeAttachments: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||||
@@ -218,7 +217,6 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
|
|
||||||
offlineCacheEnabled: false,
|
offlineCacheEnabled: false,
|
||||||
offlineCacheDays: 7,
|
offlineCacheDays: 7,
|
||||||
offlineCacheIncludeAttachments: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface SettingsState extends PersistedSettings {
|
export interface SettingsState extends PersistedSettings {
|
||||||
|
|||||||
@@ -108,9 +108,15 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
|
|||||||
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
if (s.installing || !s.cachedLatest?.apkAsset) return;
|
||||||
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
set({ installing: true, error: null, installProgress: { phase: 'downloading', progress: 0 } });
|
||||||
try {
|
try {
|
||||||
await downloadAndInstallApk(s.cachedLatest.apkAsset, (p) => {
|
await downloadAndInstallApk(
|
||||||
set({ installProgress: p });
|
{
|
||||||
});
|
asset: s.cachedLatest.apkAsset,
|
||||||
|
expectedSha256: s.cachedLatest.apkSha256,
|
||||||
|
},
|
||||||
|
(p) => {
|
||||||
|
set({ installProgress: p });
|
||||||
|
},
|
||||||
|
);
|
||||||
set({ installing: false, installProgress: null });
|
set({ installing: false, installProgress: null });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -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<string, string>();
|
||||||
|
|
||||||
|
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: <T,>(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),
|
||||||
|
}));
|
||||||
@@ -5,5 +5,6 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/__tests__/**/*.test.ts'],
|
include: ['src/**/__tests__/**/*.test.ts'],
|
||||||
|
setupFiles: ['src/test-setup.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user