mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00be851611 | ||
|
|
71ff8930a2 | ||
|
|
29b06a3a4f | ||
|
|
2911b8e7ac | ||
|
|
3d4779a689 | ||
|
|
641fde0505 | ||
|
|
6d5195d9fa | ||
|
|
40f9da244f | ||
|
|
c7ea4d757c | ||
|
|
1d89cb38cf | ||
|
|
abbbff2afa |
@@ -41,6 +41,9 @@ import { useLocaleStore } from './src/stores/locale-store';
|
||||
import { useNetworkStore } from './src/stores/network-store';
|
||||
import { useUpdatesStore } from './src/stores/updates-store';
|
||||
import { UpdateBanner } from './src/components/UpdateBanner';
|
||||
import { OfflineCacheBanner } from './src/components/OfflineCacheBanner';
|
||||
import { useOfflineCacheStore } from './src/stores/offline-cache-store';
|
||||
import { runOfflineSync } from './src/lib/offline-sync';
|
||||
import { spacing, typography, type ThemePalette } from './src/theme/tokens';
|
||||
import { useColors } from './src/theme/colors';
|
||||
|
||||
@@ -76,6 +79,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -193,6 +197,18 @@ export default function App() {
|
||||
return useNetworkStore.getState().init();
|
||||
}, []);
|
||||
|
||||
// When the network flips back on while we're authenticated-but-offline
|
||||
// (no live JMAP session), retry the session so the user lands back on
|
||||
// live data without needing to relaunch.
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
return useNetworkStore.subscribe((state, prev) => {
|
||||
if (state.online && !prev.online && !useAuthStore.getState().session) {
|
||||
void useAuthStore.getState().retrySession();
|
||||
}
|
||||
});
|
||||
}, [isAuthenticated]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -206,6 +222,24 @@ export default function App() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Offline mail cache: hydrate the cache index on launch, and kick off a
|
||||
// background sync once we have a live JMAP session and the user has the
|
||||
// feature enabled. Re-runs whenever the user changes the days window.
|
||||
const offlineCacheEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineCacheDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const haveLiveSession = useAuthStore((s) => s.session != null);
|
||||
React.useEffect(() => {
|
||||
void useOfflineCacheStore.getState().hydrate();
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (!offlineCacheEnabled || !haveLiveSession) return;
|
||||
// Slight delay so cold start doesn't compete with the inbox load.
|
||||
const t = setTimeout(() => {
|
||||
void runOfflineSync({ days: offlineCacheDays });
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [offlineCacheEnabled, offlineCacheDays, haveLiveSession]);
|
||||
|
||||
// Listen for FCM messages so the app can refresh state even when woken
|
||||
// from the background - the FirebaseMessagingService also posts the system
|
||||
// notification so the user sees it regardless of RN runtime state.
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.anonymous.bulwarkmobile
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.security.KeyChain
|
||||
import android.security.KeyChainAliasCallback
|
||||
import android.util.Base64
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.security.Principal
|
||||
import java.security.PrivateKey
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* Bridges Android's KeyChain client-certificate machinery into JS so the
|
||||
* mobile app can authenticate to a reverse proxy that asks for mTLS.
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. `pickAlias` / `getAlias` / `clearAlias` — UI flow that lets the user
|
||||
* pick a cert installed in Android's system credential store. The
|
||||
* selected alias is persisted in SharedPreferences so the choice
|
||||
* survives app restarts.
|
||||
* 2. `fetchSecure` — a fetch shim built on `HttpsURLConnection` that
|
||||
* injects the picked cert into the TLS handshake. JS-side `fetch`
|
||||
* can't present a client cert because RN's bundled OkHttp client
|
||||
* doesn't expose its KeyManager hook to userland; this method is
|
||||
* the workaround.
|
||||
*/
|
||||
class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
: ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
|
||||
@Volatile private var cachedFactory: SSLSocketFactory? = null
|
||||
@Volatile private var cachedFactoryForAlias: String? = null
|
||||
|
||||
override fun getName(): String = MODULE_NAME
|
||||
|
||||
// ── alias management ────────────────────────────────────────
|
||||
|
||||
@ReactMethod
|
||||
fun getAlias(promise: Promise) {
|
||||
promise.resolve(cachedAlias)
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearAlias(promise: Promise) {
|
||||
prefs.edit().remove(PREF_ALIAS).apply()
|
||||
cachedAlias = null
|
||||
invalidateFactory()
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the system "Choose a certificate" picker. Resolves with the
|
||||
* selected alias, or `null` when the user dismisses the picker.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun pickAlias(host: String?, promise: Promise) {
|
||||
// ReactContextBaseJavaModule no longer exposes a Kotlin synthetic
|
||||
// `currentActivity` property in RN 0.80+ (compiles as an unresolved
|
||||
// reference). The explicit Java-style getter on the application
|
||||
// context is the supported call site.
|
||||
val activity: android.app.Activity? = reactApplicationContext.getCurrentActivity()
|
||||
if (activity == null) {
|
||||
promise.reject("no_activity", "No current activity to host the cert picker")
|
||||
return
|
||||
}
|
||||
val callback = KeyChainAliasCallback { alias ->
|
||||
// Callback runs off the main thread; persist + resolve directly.
|
||||
if (alias.isNullOrBlank()) {
|
||||
promise.resolve(null)
|
||||
return@KeyChainAliasCallback
|
||||
}
|
||||
prefs.edit().putString(PREF_ALIAS, alias).apply()
|
||||
cachedAlias = alias
|
||||
invalidateFactory()
|
||||
promise.resolve(alias)
|
||||
}
|
||||
try {
|
||||
KeyChain.choosePrivateKeyAlias(
|
||||
activity,
|
||||
callback,
|
||||
/* keyTypes */ arrayOf("RSA", "EC"),
|
||||
/* issuers */ null,
|
||||
/* host */ host,
|
||||
/* port */ -1,
|
||||
/* alias */ cachedAlias,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("pick_failed", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── secure fetch ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Perform an HTTP request that presents the persisted client cert (if
|
||||
* any) during the TLS handshake.
|
||||
*
|
||||
* @param request {url, method, headers: {k:v}, bodyBase64: string|null, timeoutMs: number}
|
||||
*
|
||||
* Resolves with `{status, statusText, headers, bodyBase64}` so JS can
|
||||
* reconstruct a standard `Response`-like object.
|
||||
*/
|
||||
@ReactMethod
|
||||
fun fetchSecure(request: ReadableMap, promise: Promise) {
|
||||
val url = request.getString("url")
|
||||
if (url.isNullOrBlank()) {
|
||||
promise.reject("bad_args", "url is required")
|
||||
return
|
||||
}
|
||||
val method = (request.getString("method") ?: "GET").uppercase()
|
||||
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs") else 30_000
|
||||
val headersMap = request.getMap("headers")
|
||||
val bodyBase64 = request.getString("bodyBase64")
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
|
||||
thread(name = "BulwarkClientCert.fetch") {
|
||||
try {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = timeoutMs
|
||||
readTimeout = timeoutMs
|
||||
requestMethod = method
|
||||
instanceFollowRedirects = true
|
||||
if (bodyBytes != null) {
|
||||
doOutput = true
|
||||
setFixedLengthStreamingMode(bodyBytes.size)
|
||||
}
|
||||
}
|
||||
if (conn is HttpsURLConnection && cachedAlias != null) {
|
||||
conn.sslSocketFactory = buildOrReuseSocketFactory()
|
||||
}
|
||||
headersMap?.let { h ->
|
||||
val it = h.keySetIterator()
|
||||
while (it.hasNextKey()) {
|
||||
val k = it.nextKey()
|
||||
val v = h.getString(k)
|
||||
if (v != null) conn.setRequestProperty(k, v)
|
||||
}
|
||||
}
|
||||
conn.connect()
|
||||
if (bodyBytes != null) {
|
||||
conn.outputStream.use { out -> out.write(bodyBytes) }
|
||||
}
|
||||
|
||||
val status = conn.responseCode
|
||||
val statusText = conn.responseMessage ?: ""
|
||||
val responseStream = try {
|
||||
conn.inputStream
|
||||
} catch (_: Exception) {
|
||||
conn.errorStream
|
||||
}
|
||||
val responseBytes = if (responseStream != null) {
|
||||
val buf = ByteArrayOutputStream()
|
||||
responseStream.copyTo(buf)
|
||||
responseStream.close()
|
||||
buf.toByteArray()
|
||||
} else {
|
||||
ByteArray(0)
|
||||
}
|
||||
|
||||
val out = Arguments.createMap()
|
||||
out.putInt("status", status)
|
||||
out.putString("statusText", statusText)
|
||||
val headers = Arguments.createMap()
|
||||
// headerFields is a `Map<String?, List<String>>`; the null
|
||||
// key is the HTTP status line, which we don't need to copy.
|
||||
for ((name, values) in conn.headerFields) {
|
||||
if (name == null || values == null) continue
|
||||
headers.putString(name, values.joinToString(", "))
|
||||
}
|
||||
out.putMap("headers", headers)
|
||||
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
|
||||
conn.disconnect()
|
||||
promise.resolve(out)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("fetch_failed", e.message ?: e.javaClass.simpleName, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── internals ───────────────────────────────────────────────
|
||||
|
||||
private fun invalidateFactory() {
|
||||
cachedFactory = null
|
||||
cachedFactoryForAlias = null
|
||||
}
|
||||
|
||||
private fun buildOrReuseSocketFactory(): SSLSocketFactory {
|
||||
val alias = cachedAlias ?: throw IllegalStateException("No client-cert alias selected")
|
||||
val existing = cachedFactory
|
||||
if (existing != null && cachedFactoryForAlias == alias) return existing
|
||||
|
||||
val ctx = reactApplicationContext
|
||||
val km = KeyChainKeyManager(ctx, alias)
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(null as java.security.KeyStore?)
|
||||
val trustManagers = tmf.trustManagers
|
||||
val tm = trustManagers.firstOrNull { it is X509TrustManager } as X509TrustManager?
|
||||
?: throw IllegalStateException("No X509TrustManager available")
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(arrayOf(km), arrayOf<TrustManager>(tm), null)
|
||||
val factory = sslContext.socketFactory
|
||||
cachedFactory = factory
|
||||
cachedFactoryForAlias = alias
|
||||
return factory
|
||||
}
|
||||
|
||||
/**
|
||||
* X509ExtendedKeyManager that always returns the same KeyChain alias.
|
||||
* KeyChain.getPrivateKey / getCertificateChain are blocking; we call
|
||||
* them lazily during the TLS handshake, which is fine since the
|
||||
* fetch already runs off-main-thread.
|
||||
*/
|
||||
private class KeyChainKeyManager(
|
||||
private val context: Context,
|
||||
private val alias: String,
|
||||
) : X509ExtendedKeyManager() {
|
||||
|
||||
override fun chooseClientAlias(
|
||||
keyTypes: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String = alias
|
||||
|
||||
override fun chooseEngineClientAlias(
|
||||
keyType: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
engine: SSLEngine?,
|
||||
): String = alias
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
return try {
|
||||
KeyChain.getCertificateChain(context, this.alias)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
return try {
|
||||
KeyChain.getPrivateKey(context, this.alias)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> =
|
||||
emptyArray()
|
||||
|
||||
override fun chooseServerAlias(
|
||||
keyType: String?,
|
||||
issuers: Array<out Principal>?,
|
||||
socket: Socket?,
|
||||
): String? = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MODULE_NAME = "BulwarkClientCert"
|
||||
private const val PREFS_NAME = "bulwark_client_cert"
|
||||
private const val PREF_ALIAS = "alias"
|
||||
}
|
||||
}
|
||||
@@ -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?) {
|
||||
|
||||
@@ -7,7 +7,10 @@ import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
class BulwarkFcmPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
|
||||
listOf(BulwarkFcmModule(reactContext))
|
||||
listOf(
|
||||
BulwarkFcmModule(reactContext),
|
||||
BulwarkClientCertModule(reactContext),
|
||||
)
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
|
||||
emptyList()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.24",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.24",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -90,6 +90,24 @@ describe('email operations', () => {
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({ inMailbox: 'mb-1', hasKeyword: '$flagged' });
|
||||
});
|
||||
|
||||
it('should AND-wrap a FilterOperator instead of spreading it', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Email/query', { ids: [], total: 0 }, '0']],
|
||||
});
|
||||
|
||||
const userFilter = {
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'mb-1' }, { notKeyword: '$seen' }],
|
||||
};
|
||||
await queryEmails('mb-1', { filter: userFilter });
|
||||
|
||||
const call = mockRequest.mock.calls[0][0][0];
|
||||
expect(call[1].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'mb-1' }, userFilter],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmails', () => {
|
||||
@@ -197,7 +215,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 +226,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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { File } from 'expo-file-system';
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
|
||||
export async function uploadBlob(
|
||||
uri: string,
|
||||
@@ -20,7 +21,7 @@ export async function uploadBlob(
|
||||
// empty/garbage upload.
|
||||
const bytes = await new File(uri).bytes();
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
|
||||
+33
-1
@@ -93,10 +93,22 @@ export async function queryEmails(
|
||||
},
|
||||
): Promise<{ ids: string[]; total: number }> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const inMailbox = { inMailbox: mailboxId };
|
||||
const userFilter = options?.filter;
|
||||
// JMAP filters are either a FilterCondition or a FilterOperator (operator +
|
||||
// conditions) — never both. Spreading a FilterOperator next to `inMailbox`
|
||||
// produces a hybrid object that servers reduce to the FilterCondition,
|
||||
// silently dropping the operator's conditions (e.g. the "unread" toggle).
|
||||
const filter =
|
||||
!userFilter || Object.keys(userFilter).length === 0
|
||||
? inMailbox
|
||||
: 'operator' in userFilter
|
||||
? { operator: 'AND', conditions: [inMailbox, userFilter] }
|
||||
: { ...inMailbox, ...userFilter };
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId,
|
||||
filter: { inMailbox: mailboxId, ...options?.filter },
|
||||
filter,
|
||||
sort: options?.sort ?? [{ property: 'receivedAt', isAscending: false }],
|
||||
position: options?.position ?? 0,
|
||||
limit: options?.limit ?? 50,
|
||||
@@ -135,6 +147,26 @@ export async function getFullEmail(id: string): Promise<Email> {
|
||||
return email;
|
||||
}
|
||||
|
||||
// Batch variant for offline sync. JMAP servers cap how many objects can be
|
||||
// returned in a single Email/get; the caller should chunk to that ceiling
|
||||
// (the client exposes maxObjectsInGet via getMaxObjectsInGet()).
|
||||
export async function getFullEmails(ids: string[]): Promise<Email[]> {
|
||||
if (ids.length === 0) return [];
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request([
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids,
|
||||
properties: EMAIL_FULL_PROPERTIES,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchTextBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: 512000,
|
||||
}, '0'],
|
||||
]);
|
||||
return res.methodResponses[0][1].list as Email[];
|
||||
}
|
||||
|
||||
export async function getThread(threadId: string): Promise<Thread> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
|
||||
+39
-9
@@ -7,6 +7,7 @@ import type {
|
||||
} from './types';
|
||||
import { CAPABILITIES } from './types';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { secureFetch } from '../lib/client-cert';
|
||||
|
||||
const LEGACY_CREDENTIALS_KEY = 'jmap_credentials';
|
||||
const CREDENTIALS_PREFIX = 'jmap_credentials__';
|
||||
@@ -117,18 +118,38 @@ export class JMAPClient {
|
||||
const stored = await SecureStore.getItemAsync(credentialsKey(accountId));
|
||||
if (!stored) return false;
|
||||
|
||||
let creds: StoredCredentials;
|
||||
try {
|
||||
creds = JSON.parse(stored);
|
||||
} catch {
|
||||
// Corrupt entry — credentials can't be used. Caller should evict.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Errors past this point propagate so callers can distinguish
|
||||
// unrecoverable (AuthenticationError) from transient (NetworkError) and
|
||||
// avoid clearing credentials when the server is just unreachable.
|
||||
this.credentials = creds;
|
||||
try {
|
||||
const creds: StoredCredentials = JSON.parse(stored);
|
||||
this.credentials = creds;
|
||||
this.session = this.rewriteSessionUrls(
|
||||
await this.fetchSession(creds.serverUrl),
|
||||
creds.serverUrl,
|
||||
);
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Don't leave a half-populated client behind; the caller needs to know
|
||||
// the session is unavailable. Credentials stay in memory so a retry
|
||||
// after the network comes back doesn't need a re-login.
|
||||
this.session = null;
|
||||
this._accountId = null;
|
||||
if (err instanceof AuthenticationError) throw err;
|
||||
// Anything else is treated as transport-level. Wrapping rather than
|
||||
// rethrowing the raw fetch error gives callers a single check.
|
||||
throw new NetworkError(
|
||||
err instanceof Error ? err.message : 'Server unreachable',
|
||||
);
|
||||
}
|
||||
this._accountId = this.resolveAccountId(this.session);
|
||||
return true;
|
||||
}
|
||||
|
||||
async clearAccountCredentials(accountId: string): Promise<void> {
|
||||
@@ -214,7 +235,7 @@ export class JMAPClient {
|
||||
|
||||
private async fetchSession(baseUrl: string): Promise<JMAPSession> {
|
||||
const url = `${baseUrl}/.well-known/jmap`;
|
||||
const response = await fetch(url, {
|
||||
const response = await secureFetch(url, {
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
Accept: 'application/json',
|
||||
@@ -259,7 +280,7 @@ export class JMAPClient {
|
||||
methodCalls,
|
||||
};
|
||||
|
||||
const response = await fetch(this.session.apiUrl, {
|
||||
const response = await secureFetch(this.session.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -314,7 +335,7 @@ export class JMAPClient {
|
||||
|
||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
const response = await fetch(url, {
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: this.authHeader },
|
||||
});
|
||||
if (response.status === 401) throw new AuthenticationError('Session expired');
|
||||
@@ -332,6 +353,15 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Server unreachable / transient transport failure. Callers should keep
|
||||
// stored credentials and surface an offline state rather than logging out.
|
||||
export class NetworkError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
retryAfterMs: number;
|
||||
constructor(retryAfterMs: number) {
|
||||
|
||||
+27
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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> {
|
||||
@@ -32,6 +71,14 @@ export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise<La
|
||||
assets: ReleaseAsset[];
|
||||
};
|
||||
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 {
|
||||
tag: 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,
|
||||
body: json.body || '',
|
||||
apkAsset,
|
||||
apkSha256,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
|
||||
<View style={[styles.webContainer, { height }]}>
|
||||
<WebView
|
||||
ref={webviewRef}
|
||||
originWhitelist={['about:*']}
|
||||
originWhitelist={['about:blank']}
|
||||
source={source}
|
||||
javaScriptEnabled
|
||||
injectedJavaScript={HEIGHT_REPORTER}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Live banner that mirrors the UpdateBanner's footprint, surfaced while the
|
||||
// offline mail sync is running. Hidden once the sync settles to idle/done.
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { CloudDownload, X } from 'lucide-react-native';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import { formatBytes } from '../lib/offline-sync';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
|
||||
export function OfflineCacheBanner(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
|
||||
const resetSync = useOfflineCacheStore((s) => s.resetSync);
|
||||
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
|
||||
// permanent fixture; the Settings screen still shows the cache stats.
|
||||
React.useEffect(() => {
|
||||
if (sync.phase === 'done' || sync.phase === 'cancelled') {
|
||||
const t = setTimeout(() => resetSync(), 4000);
|
||||
setHideTimer(t);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
return undefined;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sync.phase, resetSync]);
|
||||
|
||||
if (sync.phase === 'idle') return null;
|
||||
|
||||
const pct =
|
||||
sync.total > 0
|
||||
? Math.min(100, Math.round((sync.completed / sync.total) * 100))
|
||||
: sync.phase === 'done' ? 100 : 0;
|
||||
|
||||
let title = 'Offline sync';
|
||||
let subtitle = '';
|
||||
switch (sync.phase) {
|
||||
case 'scanning':
|
||||
title = 'Syncing offline mail';
|
||||
subtitle = 'Scanning recent messages…';
|
||||
break;
|
||||
case 'fetching':
|
||||
title = 'Syncing offline mail';
|
||||
subtitle = `${sync.completed}/${sync.total} • ${formatBytes(sync.bytes)}`;
|
||||
break;
|
||||
case 'done':
|
||||
title = 'Offline mail ready';
|
||||
subtitle = sync.fetched > 0
|
||||
? `${sync.fetched} new message${sync.fetched === 1 ? '' : 's'} cached • ${formatBytes(sync.bytes)}`
|
||||
: 'Already up to date';
|
||||
break;
|
||||
case 'cancelled':
|
||||
title = 'Sync cancelled';
|
||||
subtitle = `${sync.completed}/${sync.total} processed`;
|
||||
break;
|
||||
case 'error':
|
||||
title = 'Offline sync failed';
|
||||
subtitle = sync.message ?? 'Unable to download';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const showCancel = sync.phase === 'scanning' || sync.phase === 'fetching';
|
||||
const showDismiss = sync.phase === 'done' || sync.phase === 'cancelled' || sync.phase === 'error';
|
||||
const isError = sync.phase === 'error';
|
||||
|
||||
return (
|
||||
<View style={[styles.banner, isError && styles.bannerError]}>
|
||||
<CloudDownload size={16} color={c.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{!!subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
|
||||
{(sync.phase === 'fetching' || sync.phase === 'scanning') && (
|
||||
<View style={styles.progressTrack}>
|
||||
<View style={[styles.progressFill, { width: `${pct}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{showCancel && (
|
||||
<Pressable style={styles.cancelButton} onPress={requestAbort} hitSlop={6}>
|
||||
<Text style={styles.cancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{showDismiss && (
|
||||
<Pressable style={styles.dismiss} onPress={resetSync} hitSlop={8}>
|
||||
<X size={14} color={c.primaryForeground} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.primary,
|
||||
},
|
||||
bannerError: { backgroundColor: c.error },
|
||||
title: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||
subtitle: { ...typography.caption, color: c.primaryForeground, opacity: 0.85, marginTop: 2 },
|
||||
progressTrack: {
|
||||
marginTop: 6,
|
||||
height: 3,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: c.primaryForeground,
|
||||
},
|
||||
cancelButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
cancelText: { ...typography.captionMedium, color: c.primaryForeground },
|
||||
dismiss: { padding: 4 },
|
||||
});
|
||||
}
|
||||
@@ -263,20 +263,30 @@ function buildEditorHtml(opts: {
|
||||
},
|
||||
insertLink: function (url, label) {
|
||||
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 hasSelection = sel && sel.rangeCount && !sel.getRangeAt(0).collapsed;
|
||||
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.
|
||||
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');
|
||||
}
|
||||
});
|
||||
} 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 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>';
|
||||
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 }]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
originWhitelist={['about:*']}
|
||||
originWhitelist={['about:blank']}
|
||||
source={{ html }}
|
||||
onMessage={onMessage}
|
||||
javaScriptEnabled
|
||||
@@ -433,9 +443,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(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:');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -487,7 +487,9 @@ function makeStyles(c: ThemePalette) {
|
||||
top: 0, bottom: 0, left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626', // matches webmail bg-secondary (dark mode)
|
||||
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
|
||||
// dark-mode value, which left light mode unreadable (dark bg + dark text).
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
@@ -537,7 +539,7 @@ function makeStyles(c: ThemePalette) {
|
||||
marginHorizontal: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: spacing.sm,
|
||||
backgroundColor: '#1f1f1f',
|
||||
backgroundColor: c.popover,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
|
||||
@@ -191,7 +191,7 @@ function makeStyles(c: ThemePalette) {
|
||||
left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
|
||||
@@ -228,7 +228,7 @@ function makeStyles(c: ThemePalette) {
|
||||
top: 0, bottom: 0, left: 0,
|
||||
width: '85%',
|
||||
maxWidth: 340,
|
||||
backgroundColor: '#262626',
|
||||
backgroundColor: c.secondary,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: c.border,
|
||||
},
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Linking, Alert } from 'react-native';
|
||||
import { View, Text, StyleSheet, Pressable, Linking, Alert, ActivityIndicator } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import { ExternalLink } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import { CloudDownload, ExternalLink } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
|
||||
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
|
||||
|
||||
const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0';
|
||||
const GIT_COMMIT = (Constants.expoConfig?.extra as { commit?: string } | undefined)?.commit ?? 'dev';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: '1', label: 'Last 24 hours' },
|
||||
{ value: '3', label: 'Last 3 days' },
|
||||
{ value: '7', label: 'Last 7 days' },
|
||||
{ value: '14', label: 'Last 14 days' },
|
||||
{ value: '30', label: 'Last 30 days' },
|
||||
{ value: '90', label: 'Last 90 days' },
|
||||
];
|
||||
|
||||
function formatRelativeTime(ms: number | undefined): string {
|
||||
if (!ms) return 'never';
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 60_000) return 'just now';
|
||||
if (diff < 60 * 60_000) return `${Math.round(diff / 60_000)} min ago`;
|
||||
if (diff < 24 * 60 * 60_000) return `${Math.round(diff / (60 * 60_000))} h ago`;
|
||||
return `${Math.round(diff / (24 * 60 * 60_000))} d ago`;
|
||||
}
|
||||
|
||||
const DEBUG_CATEGORIES = [
|
||||
{ id: 'jmap', label: 'JMAP', description: 'JMAP request and response logs.' },
|
||||
{ id: 'auth', label: 'Auth', description: 'Authentication and session events.' },
|
||||
@@ -36,9 +56,36 @@ export function AboutDataSettings() {
|
||||
const [syncEnabled, setSyncEnabled] = useState(true);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
const offlineEnabled = useSettingsStore((s) => s.offlineCacheEnabled);
|
||||
const offlineDays = useSettingsStore((s) => s.offlineCacheDays);
|
||||
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
||||
const cacheCount = useOfflineCacheStore((s) => s.totalCount());
|
||||
const cacheBytes = useOfflineCacheStore((s) => s.totalSize());
|
||||
const cacheHydrated = useOfflineCacheStore((s) => s.hydrated);
|
||||
const cacheHydrate = useOfflineCacheStore((s) => s.hydrate);
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const clearAllCache = useOfflineCacheStore((s) => s.clearAll);
|
||||
const syncBusy = sync.phase === 'scanning' || sync.phase === 'fetching';
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
if (!cacheHydrated) void cacheHydrate();
|
||||
}, [hydrated, hydrate, cacheHydrated, cacheHydrate]);
|
||||
|
||||
const handleSyncNow = () => {
|
||||
void runOfflineSync({ days: offlineDays });
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
Alert.alert(
|
||||
'Clear offline mail',
|
||||
`Remove ${cacheCount} cached message${cacheCount === 1 ? '' : 's'} (${formatBytes(cacheBytes)})?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{ text: 'Clear', style: 'destructive', onPress: () => { void clearAllCache(); } },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetExternalContentPolicy('ask');
|
||||
@@ -72,6 +119,80 @@ export function AboutDataSettings() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<SettingsSection
|
||||
title="Offline mail"
|
||||
description="Download recent messages so they open instantly and remain readable without a connection. Bodies only — attachments are still fetched on demand."
|
||||
>
|
||||
<SettingItem
|
||||
label="Cache recent mail"
|
||||
description="When on, the app keeps message bodies for the selected window on this device."
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={offlineEnabled}
|
||||
onChange={(v) => updateSetting('offlineCacheEnabled', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
label="Window"
|
||||
description="How far back to cache, measured by message receipt date."
|
||||
>
|
||||
<Select
|
||||
value={String(offlineDays)}
|
||||
onChange={(v) => updateSetting('offlineCacheDays', Number(v))}
|
||||
options={DAY_OPTIONS}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<View style={styles.cacheStatsBox}>
|
||||
<View style={styles.cacheStatsHeader}>
|
||||
<CloudDownload size={16} color={c.textSecondary} />
|
||||
<Text style={styles.cacheStatsTitle}>
|
||||
{cacheCount === 0
|
||||
? 'Nothing cached yet'
|
||||
: `${cacheCount} message${cacheCount === 1 ? '' : 's'} • ${formatBytes(cacheBytes)}`}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.cacheStatsSub}>
|
||||
{sync.phase === 'fetching'
|
||||
? `Downloading ${sync.completed}/${sync.total}…`
|
||||
: sync.phase === 'scanning'
|
||||
? 'Scanning recent mail…'
|
||||
: sync.phase === 'error'
|
||||
? `Last sync failed: ${sync.message ?? 'unknown error'}`
|
||||
: `Last sync ${formatRelativeTime(sync.finishedAt)}`}
|
||||
</Text>
|
||||
{syncBusy && sync.total > 0 && (
|
||||
<View style={styles.progressTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.min(100, Math.round((sync.completed / sync.total) * 100))}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.cacheActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleSyncNow}
|
||||
disabled={!offlineEnabled || syncBusy}
|
||||
icon={syncBusy ? <ActivityIndicator size="small" color={c.text} /> : undefined}
|
||||
>
|
||||
{syncBusy ? 'Syncing…' : 'Sync now'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleClearCache}
|
||||
disabled={cacheCount === 0 || syncBusy}
|
||||
>
|
||||
Clear cache
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Advanced" description="Low-level options and troubleshooting.">
|
||||
<SettingItem label="Debug mode" description="Enable verbose logging for support and development.">
|
||||
<ToggleSwitch checked={debugMode} onChange={setDebugMode} />
|
||||
@@ -170,5 +291,29 @@ function makeStyles(c: ThemePalette) {
|
||||
color: c.mutedForeground,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
cacheStatsBox: {
|
||||
marginTop: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.muted,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cacheStatsHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
cacheStatsTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
|
||||
cacheStatsSub: { ...typography.caption, color: c.mutedForeground },
|
||||
progressTrack: {
|
||||
height: 4,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.border,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: { height: '100%', backgroundColor: c.primary },
|
||||
cacheActions: { flexDirection: 'row', gap: spacing.sm, marginTop: 4 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, Shield, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Alert, View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2 } from 'lucide-react-native';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
|
||||
import Input from '../Input';
|
||||
import Button from '../Button';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import {
|
||||
clearClientCertAlias,
|
||||
getClientCertAlias,
|
||||
isClientCertSupported,
|
||||
pickClientCertAlias,
|
||||
} from '../../lib/client-cert';
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
@@ -30,8 +37,96 @@ export function AccountSecuritySettings() {
|
||||
const [twoFactor, setTwoFactor] = useState(false);
|
||||
const [appPwds] = useState<{ id: string; label: string; created: string }[]>([]);
|
||||
|
||||
const certSupported = isClientCertSupported();
|
||||
const [certAlias, setCertAlias] = useState<string | null>(null);
|
||||
const [certBusy, setCertBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certSupported) return;
|
||||
void getClientCertAlias().then(setCertAlias);
|
||||
}, [certSupported]);
|
||||
|
||||
const onPickCert = async () => {
|
||||
if (certBusy) return;
|
||||
setCertBusy(true);
|
||||
try {
|
||||
// Hint the picker with the JMAP server host so it can pre-filter to certs
|
||||
// issued for it when the OEM picker honours that.
|
||||
const host = jmapClient.serverUrl ? new URL(jmapClient.serverUrl).hostname : null;
|
||||
const alias = await pickClientCertAlias(host);
|
||||
setCertAlias(alias);
|
||||
} catch (err) {
|
||||
Alert.alert('Pick failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCertBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onClearCert = async () => {
|
||||
if (certBusy) return;
|
||||
Alert.alert(
|
||||
'Stop using client certificate?',
|
||||
'New requests will no longer present a certificate. The certificate stays installed in Android.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Clear',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setCertBusy(true);
|
||||
try {
|
||||
await clearClientCertAlias();
|
||||
setCertAlias(null);
|
||||
} finally {
|
||||
setCertBusy(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{certSupported && (
|
||||
<SettingsSection
|
||||
title="TLS client certificate"
|
||||
description="Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS."
|
||||
>
|
||||
<View style={styles.certRow}>
|
||||
<ShieldCheck size={18} color={certAlias ? c.success : c.mutedForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.certStatus}>
|
||||
{certAlias ? `Active: ${certAlias}` : 'Not set'}
|
||||
</Text>
|
||||
<Text style={styles.certHint}>
|
||||
Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.certActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onPickCert(); }}
|
||||
>
|
||||
{certAlias ? 'Choose another' : 'Pick certificate'}
|
||||
</Button>
|
||||
{certAlias && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={certBusy}
|
||||
onPress={() => { void onClearCert(); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Password" description="Change the password for this account.">
|
||||
<View style={{ gap: spacing.md }}>
|
||||
<View style={styles.pwField}>
|
||||
@@ -196,5 +291,14 @@ function makeStyles(c: ThemePalette) {
|
||||
justifyContent: 'center',
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
certRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
certStatus: { ...typography.bodyMedium, color: c.text },
|
||||
certHint: { ...typography.caption, color: c.mutedForeground, marginTop: 4 },
|
||||
certActions: { flexDirection: 'row', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Bridge to the native BulwarkClientCert module. Wraps the alias pick / get /
|
||||
// clear flow and exposes a `secureFetch` that mirrors the global `fetch` API
|
||||
// closely enough that callers can swap them out.
|
||||
//
|
||||
// On non-Android platforms (or when the user has not picked a cert) we fall
|
||||
// straight back to the global `fetch` so we don't pay any bridge overhead.
|
||||
|
||||
type Native = {
|
||||
getAlias(): Promise<string | null>;
|
||||
pickAlias(host: string | null): Promise<string | null>;
|
||||
clearAlias(): Promise<void>;
|
||||
fetchSecure(request: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string | null;
|
||||
timeoutMs: number;
|
||||
}): Promise<{
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Lazy-loaded so unit tests (which run in plain Node) don't have to parse the
|
||||
// `react-native` entry point. We resolve the module on first access; the
|
||||
// result is cached by the require cache.
|
||||
let nativeProbed = false;
|
||||
let nativeModule: Native | null = null;
|
||||
|
||||
function getNative(): Native | null {
|
||||
if (nativeProbed) return nativeModule;
|
||||
nativeProbed = true;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const rn = require('react-native') as {
|
||||
Platform: { OS: string };
|
||||
NativeModules: Record<string, unknown>;
|
||||
};
|
||||
if (rn.Platform.OS !== 'android') return (nativeModule = null);
|
||||
nativeModule = (rn.NativeModules.BulwarkClientCert as Native | undefined) ?? null;
|
||||
} catch {
|
||||
nativeModule = null;
|
||||
}
|
||||
return nativeModule;
|
||||
}
|
||||
|
||||
// Cached so the hot path can short-circuit without a bridge round-trip.
|
||||
let cachedAlias: string | null | undefined;
|
||||
|
||||
export function isClientCertSupported(): boolean {
|
||||
return getNative() != null;
|
||||
}
|
||||
|
||||
export async function getClientCertAlias(): Promise<string | null> {
|
||||
const native = getNative();
|
||||
if (!native) return null;
|
||||
if (cachedAlias !== undefined) return cachedAlias;
|
||||
cachedAlias = await native.getAlias();
|
||||
return cachedAlias;
|
||||
}
|
||||
|
||||
export async function pickClientCertAlias(host: string | null): Promise<string | null> {
|
||||
const native = getNative();
|
||||
if (!native) throw new Error('Client certificates require Android');
|
||||
const alias = await native.pickAlias(host);
|
||||
cachedAlias = alias;
|
||||
return alias;
|
||||
}
|
||||
|
||||
export async function clearClientCertAlias(): Promise<void> {
|
||||
const native = getNative();
|
||||
if (!native) return;
|
||||
await native.clearAlias();
|
||||
cachedAlias = null;
|
||||
}
|
||||
|
||||
// ── secureFetch ───────────────────────────────────────────────
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let bin = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
|
||||
}
|
||||
return globalThis.btoa ? globalThis.btoa(bin) : btoa(bin);
|
||||
}
|
||||
|
||||
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 bodyToBase64(body: BodyInit | null | undefined): Promise<string | null> {
|
||||
if (body == null) return null;
|
||||
if (typeof body === 'string') {
|
||||
// UTF-8 encode then base64.
|
||||
const encoder = new TextEncoder();
|
||||
return bytesToBase64(encoder.encode(body));
|
||||
}
|
||||
if (body instanceof ArrayBuffer) {
|
||||
return bytesToBase64(new Uint8Array(body));
|
||||
}
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
const view = body as ArrayBufferView;
|
||||
return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
||||
}
|
||||
// Blob / FormData / URLSearchParams / ReadableStream are not used in the
|
||||
// current code paths. If they appear, the caller should pre-serialize.
|
||||
throw new Error(`secureFetch: unsupported body type ${(body as object).constructor?.name}`);
|
||||
}
|
||||
|
||||
function flattenHeaders(input: HeadersInit | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!input) return out;
|
||||
if (Array.isArray(input)) {
|
||||
for (const [k, v] of input) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
if (typeof Headers !== 'undefined' && input instanceof Headers) {
|
||||
input.forEach((v, k) => { out[k] = v; });
|
||||
return out;
|
||||
}
|
||||
return { ...(input as Record<string, string>) };
|
||||
}
|
||||
|
||||
function buildResponse(raw: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
bodyBase64: string;
|
||||
}): Response {
|
||||
const bytes = raw.bodyBase64 ? base64ToBytes(raw.bodyBase64) : new Uint8Array(0);
|
||||
const blob = new Blob([bytes as unknown as BlobPart]);
|
||||
return new Response(blob, {
|
||||
status: raw.status,
|
||||
statusText: raw.statusText,
|
||||
headers: raw.headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `fetch` that routes through the native client when
|
||||
* the user has selected a client certificate. When no cert is set (or we're
|
||||
* on iOS), it just calls the platform `fetch` and the caller pays no
|
||||
* bridge overhead.
|
||||
*/
|
||||
export async function secureFetch(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const native = getNative();
|
||||
if (!native) return fetch(url, init);
|
||||
const alias = await getClientCertAlias();
|
||||
if (!alias) return fetch(url, init);
|
||||
|
||||
const method = init?.method ?? 'GET';
|
||||
const headers = flattenHeaders(init?.headers);
|
||||
const bodyBase64 = await bodyToBase64(init?.body);
|
||||
// Force Content-Length to come from the body bytes - some servers (Stalwart
|
||||
// behind nginx in particular) reject chunked uploads when an explicit
|
||||
// length isn't provided alongside the bytes.
|
||||
if (bodyBase64 != null) {
|
||||
const decoded = base64ToBytes(bodyBase64);
|
||||
headers['Content-Length'] = String(decoded.byteLength);
|
||||
}
|
||||
try {
|
||||
const raw = await native.fetchSecure({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
bodyBase64,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
return buildResponse(raw);
|
||||
} catch (err) {
|
||||
// Surface as a TypeError to mimic fetch's network-error contract; keep
|
||||
// the original message for diagnostics.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new TypeError(`secureFetch failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
+40
-14
@@ -1,7 +1,8 @@
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { Directory, File, Paths } from 'expo-file-system';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { getDownloadUrl } from '../api/blob';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
|
||||
const RFC822 = 'message/rfc822';
|
||||
|
||||
@@ -21,6 +22,40 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
|
||||
// itself with its bundled SharingFileProvider before launching the share
|
||||
// intent, so we must NOT pre-translate to `file.contentUri`.
|
||||
|
||||
// Routes the download via the client-cert-aware native module when the user
|
||||
// has picked a cert, and via expo-file-system's native streaming downloader
|
||||
// otherwise. The streaming path scales to large attachments without buffering
|
||||
// in JS, so we keep using it as the default.
|
||||
async function downloadInto(
|
||||
url: string,
|
||||
dest: File,
|
||||
parent: Directory,
|
||||
): Promise<File> {
|
||||
const alias = await getClientCertAlias();
|
||||
if (!alias) {
|
||||
// The static returns a separately-typed `FileSystemFile`; we already
|
||||
// have a fully-typed `File` referencing the same uri, so we ignore the
|
||||
// return value and re-use our `dest` reference for downstream code.
|
||||
await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
return dest;
|
||||
}
|
||||
const response = await secureFetch(url, {
|
||||
headers: { Authorization: authHeader() },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed: ${response.status}`);
|
||||
}
|
||||
if (!parent.exists) parent.create({ intermediates: true, idempotent: true });
|
||||
if (dest.exists) dest.delete();
|
||||
const buffer = await response.arrayBuffer();
|
||||
dest.create();
|
||||
dest.write(new Uint8Array(buffer));
|
||||
return dest;
|
||||
}
|
||||
|
||||
export async function shareAttachment(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
@@ -30,10 +65,7 @@ export async function shareAttachment(
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.cache, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
@@ -57,10 +89,7 @@ export async function downloadAttachment(
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const dest = new File(Paths.document, filename);
|
||||
const url = getDownloadUrl(blobId, filename, mimeType);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.document);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
@@ -72,7 +101,7 @@ export async function downloadAttachment(
|
||||
|
||||
export async function fetchRawEmail(blobId: string): Promise<string> {
|
||||
const url = getDownloadUrl(blobId, 'email.eml', RFC822);
|
||||
const r = await fetch(url, { headers: { Authorization: authHeader() } });
|
||||
const r = await secureFetch(url, { headers: { Authorization: authHeader() } });
|
||||
if (!r.ok) throw new Error(`Download failed: ${r.status}`);
|
||||
return r.text();
|
||||
}
|
||||
@@ -85,10 +114,7 @@ function safeFilename(subject: string | undefined): string {
|
||||
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
|
||||
const dest = new File(Paths.cache, safeFilename(subject));
|
||||
const url = getDownloadUrl(blobId, dest.name, RFC822);
|
||||
const downloaded = await File.downloadFileAsync(url, dest, {
|
||||
headers: { Authorization: authHeader() },
|
||||
idempotent: true,
|
||||
});
|
||||
const downloaded = await downloadInto(url, dest, Paths.cache);
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
|
||||
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 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<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(
|
||||
asset: ReleaseAsset,
|
||||
params: InstallParams | ReleaseAsset,
|
||||
onProgress?: InstallProgressListener,
|
||||
): 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') {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Background sync that fills the offline mail cache. Runs the discovery
|
||||
// query (Email/query filtered by `after`), then fetches full bodies in
|
||||
// batches, reporting progress to the offline-cache-store so the
|
||||
// OfflineCacheBanner and the Settings screen can show live updates.
|
||||
|
||||
import { jmapClient } from '../api/jmap-client';
|
||||
import { queryEmailsByFilter, getFullEmails } from '../api/email';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
// Approximate the on-disk size of a serialised Email so we can show "Y MB
|
||||
// downloaded" without measuring AsyncStorage usage. Body values dominate; the
|
||||
// envelope is small. Using JSON.stringify().length is correct in code-units
|
||||
// not bytes, but it's close enough for a UI-facing estimate.
|
||||
function approxSize(email: Email): number {
|
||||
try {
|
||||
return JSON.stringify(email).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap so a misconfigured "30 days" against a noisy account doesn't try
|
||||
// to enumerate 100k messages.
|
||||
const DISCOVERY_LIMIT = 5000;
|
||||
// Each Email/get request is bounded by the server's maxObjectsInGet. We
|
||||
// further chunk to keep individual responses small (a 50-message response
|
||||
// with full bodies + body values is already several MB).
|
||||
const FETCH_CHUNK_FALLBACK = 25;
|
||||
|
||||
export interface RunOptions {
|
||||
days: number;
|
||||
}
|
||||
|
||||
export async function runOfflineSync(opts: RunOptions): Promise<void> {
|
||||
const cache = useOfflineCacheStore.getState();
|
||||
// If a sync is in flight, request its abort and wait for the next caller
|
||||
// to start fresh. We don't queue — the most recent intent wins.
|
||||
if (cache.sync.phase === 'scanning' || cache.sync.phase === 'fetching') {
|
||||
cache.requestAbort();
|
||||
return;
|
||||
}
|
||||
if (!cache.hydrated) await cache.hydrate();
|
||||
cache.resetSync();
|
||||
|
||||
const startedAt = Date.now();
|
||||
cache.setSyncState({ phase: 'scanning', startedAt });
|
||||
|
||||
const since = new Date(startedAt - opts.days * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
let ids: string[] = [];
|
||||
try {
|
||||
ids = await queryEmailsByFilter({ after: since }, DISCOVERY_LIMIT);
|
||||
} catch (err) {
|
||||
cache.setSyncState({
|
||||
phase: 'error',
|
||||
message: err instanceof Error ? err.message : 'Discovery query failed',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop entries from the cache that fell out of the lookback window — the
|
||||
// user expects the cache to track "the last X days", not grow forever.
|
||||
const keepSet = new Set(ids);
|
||||
const stale = Object.keys(cache.index.entries).filter((id) => !keepSet.has(id));
|
||||
if (stale.length > 0) {
|
||||
await cache.remove(stale);
|
||||
}
|
||||
|
||||
const total = ids.length;
|
||||
cache.setSyncState({ phase: 'fetching', total, completed: 0, fetched: 0, bytes: 0 });
|
||||
|
||||
// Skip already-cached ids — bodies on disk are immutable per messageId.
|
||||
const toFetch = ids.filter((id) => !cache.has(id));
|
||||
const skipped = total - toFetch.length;
|
||||
cache.setSyncState({ completed: skipped });
|
||||
|
||||
if (toFetch.length === 0) {
|
||||
cache.setSyncState({
|
||||
phase: 'done',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkSize = Math.min(
|
||||
FETCH_CHUNK_FALLBACK,
|
||||
Math.max(1, jmapClient.getMaxObjectsInGet()),
|
||||
);
|
||||
|
||||
let fetched = 0;
|
||||
let bytes = 0;
|
||||
let completed = skipped;
|
||||
|
||||
for (let i = 0; i < toFetch.length; i += chunkSize) {
|
||||
if (useOfflineCacheStore.getState().consumeAbort()) {
|
||||
cache.setSyncState({
|
||||
phase: 'cancelled',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = toFetch.slice(i, i + chunkSize);
|
||||
let emails: Email[];
|
||||
try {
|
||||
emails = await getFullEmails(chunk);
|
||||
} catch (err) {
|
||||
cache.setSyncState({
|
||||
phase: 'error',
|
||||
message: err instanceof Error ? err.message : 'Fetch failed',
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const email of emails) {
|
||||
const size = approxSize(email);
|
||||
await cache.put(email, size);
|
||||
fetched += 1;
|
||||
bytes += size;
|
||||
completed += 1;
|
||||
}
|
||||
|
||||
// Some chunked ids may have been deleted server-side between query and
|
||||
// fetch — they don't come back. Account for that in the completed count
|
||||
// so the progress bar still finishes at 100%.
|
||||
completed += chunk.length - emails.length;
|
||||
|
||||
cache.setSyncState({ completed, fetched, bytes });
|
||||
}
|
||||
|
||||
cache.setSyncState({
|
||||
phase: 'done',
|
||||
completed: total,
|
||||
fetched,
|
||||
bytes,
|
||||
finishedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
@@ -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 { 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 */}
|
||||
<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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -210,14 +210,20 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title */}
|
||||
{/* Header - matches webmail mobile: h-14, border-b, back button + icon + title.
|
||||
Settings is a tab root, so the back button is only rendered when a
|
||||
parent passes onBack (it'd otherwise lead nowhere). */}
|
||||
<View style={styles.header}>
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
{onBack ? (
|
||||
<Pressable
|
||||
onPress={onBack}
|
||||
style={({ pressed }) => [styles.headerBackBtn, pressed && styles.headerBackBtnPressed]}
|
||||
>
|
||||
<ArrowLeft size={20} color={c.text} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.headerLeftSpacer} />
|
||||
)}
|
||||
<Settings size={20} color={c.mutedForeground} />
|
||||
<Text style={styles.headerTitle}>{t('settings.title', 'Settings')}</Text>
|
||||
</View>
|
||||
@@ -310,6 +316,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
headerBackBtnPressed: { backgroundColor: c.accent },
|
||||
headerLeftSpacer: { width: spacing.sm },
|
||||
headerTitle: { ...typography.h3, color: c.text },
|
||||
|
||||
scrollArea: { flex: 1 },
|
||||
|
||||
@@ -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<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(() => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { jmapClient, AuthenticationError } from '../api/jmap-client';
|
||||
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
|
||||
import type { JMAPSession } from '../api/types';
|
||||
import { useAccountStore } from './account-store';
|
||||
import { useEmailStore } from './email-store';
|
||||
@@ -39,6 +39,7 @@ export interface AuthState {
|
||||
logoutAll: () => Promise<void>;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
restoreSession: () => Promise<boolean>;
|
||||
retrySession: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
@@ -286,15 +287,56 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
if (!ok) {
|
||||
accountStore.removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true });
|
||||
return false;
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(target.id);
|
||||
if (!ok) {
|
||||
// No stored credentials (or corrupt) — genuine logout.
|
||||
accountStore.removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true });
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof NetworkError) {
|
||||
// Server unreachable. Keep credentials, mark account offline, and
|
||||
// surface the cached UI so the user can still browse persisted
|
||||
// mail / contacts / calendar. The login screen would lose their
|
||||
// settings without recourse, which is the bug we're fixing here.
|
||||
accountStore.setActiveAccount(target.id);
|
||||
accountStore.updateAccount(target.id, {
|
||||
isConnected: false,
|
||||
hasError: true,
|
||||
errorMessage: err.message,
|
||||
});
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
hasRestoredSession: true,
|
||||
error: null,
|
||||
serverUrl: target.serverUrl,
|
||||
username: target.username,
|
||||
session: null,
|
||||
accountId: null,
|
||||
activeAccountId: target.id,
|
||||
client: jmapClient,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (err instanceof AuthenticationError) {
|
||||
// Server reachable but credentials rejected — drop them.
|
||||
await jmapClient.clearAccountCredentials(target.id).catch(() => undefined);
|
||||
accountStore.removeAccount(target.id);
|
||||
set({ isLoading: false, hasRestoredSession: true, error: 'Session expired' });
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
accountStore.setActiveAccount(target.id);
|
||||
accountStore.updateAccount(target.id, { isConnected: true, hasError: false });
|
||||
accountStore.updateAccount(target.id, {
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
|
||||
const session = jmapClient.currentSession!;
|
||||
applyConnectedState(set, session, target.serverUrl, target.username, target.id);
|
||||
@@ -309,5 +351,51 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Re-attempt session establishment for the currently active account
|
||||
// without disturbing UI state on failure. Used by the network-recovery
|
||||
// watcher and any explicit "retry" button. Idempotent: returns true if
|
||||
// a session is already live.
|
||||
retrySession: async () => {
|
||||
const { activeAccountId, session } = get();
|
||||
if (!activeAccountId) return false;
|
||||
if (session) return true;
|
||||
const accountStore = useAccountStore.getState();
|
||||
const target = accountStore.getAccountById(activeAccountId);
|
||||
if (!target) return false;
|
||||
try {
|
||||
const ok = await jmapClient.loadAccount(activeAccountId);
|
||||
if (!ok) return false;
|
||||
accountStore.updateAccount(activeAccountId, {
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
const fresh = jmapClient.currentSession!;
|
||||
applyConnectedState(set, fresh, target.serverUrl, target.username, activeAccountId);
|
||||
refetchFeatureStores();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
// Now we know the credentials are bad — fall back to logout flow.
|
||||
await jmapClient.clearAccountCredentials(activeAccountId).catch(() => undefined);
|
||||
accountStore.removeAccount(activeAccountId);
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
hasRestoredSession: true,
|
||||
error: 'Session expired',
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
session: null,
|
||||
accountId: null,
|
||||
activeAccountId: null,
|
||||
client: null,
|
||||
});
|
||||
}
|
||||
// NetworkError or anything else: stay where we are.
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
} from '../api/email';
|
||||
import { toWildcardQuery } from '../lib/search-utils';
|
||||
import { useSettingsStore } from './settings-store';
|
||||
import { useOfflineCacheStore } from './offline-cache-store';
|
||||
|
||||
export interface EmailFilters {
|
||||
from?: string;
|
||||
@@ -56,6 +58,7 @@ export interface EmailState {
|
||||
selectMailbox: (mailboxId: string) => Promise<void>;
|
||||
loadMoreEmails: () => Promise<void>;
|
||||
refreshEmails: () => Promise<void>;
|
||||
handleStateChange: (change: StateChange) => Promise<void>;
|
||||
getEmailDetail: (id: string) => Promise<Email>;
|
||||
markRead: (emailId: string) => Promise<void>;
|
||||
markUnread: (emailId: string) => Promise<void>;
|
||||
@@ -149,15 +152,43 @@ export const useEmailStore = create<EmailState>()(
|
||||
filters: {},
|
||||
pendingUndo: null,
|
||||
});
|
||||
|
||||
// Seed from the offline cache immediately so changing folders shows
|
||||
// something even before the network responds — and is the fallback
|
||||
// when offline. The network result will replace this once it arrives.
|
||||
const cacheStore = useOfflineCacheStore.getState();
|
||||
if (!cacheStore.hydrated) await cacheStore.hydrate();
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
let cached: Email[] = [];
|
||||
if (cacheStore.totalCount() > 0) {
|
||||
try {
|
||||
cached = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50));
|
||||
if (cached.length > 0 && get().currentMailboxId === mailboxId) {
|
||||
set({ emails: cached, totalEmails: cached.length });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[email-store] cache seed failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const filter = buildJmapFilter(mailboxId, '', {});
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
|
||||
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
|
||||
// Race guard: don't overwrite if the user already moved to a different
|
||||
// mailbox while this request was in flight.
|
||||
if (get().currentMailboxId !== mailboxId) return;
|
||||
set({ emails, totalEmails: total, loading: false });
|
||||
} catch (err) {
|
||||
console.warn('[email-store] selectMailbox failed:', err);
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
|
||||
if (get().currentMailboxId !== mailboxId) return;
|
||||
// Keep whatever the cache seeded with; only surface the error when the
|
||||
// cache is empty. With cached emails visible the OfflineBanner already
|
||||
// tells the user the data is stale.
|
||||
set({
|
||||
loading: false,
|
||||
error: cached.length > 0 ? null : (err instanceof Error ? err.message : 'Failed to load emails'),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -182,7 +213,7 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
refreshEmails: async () => {
|
||||
const { currentMailboxId, searchQuery, filters } = get();
|
||||
const { currentMailboxId, searchQuery, filters, emails: existing } = get();
|
||||
if (!currentMailboxId) return;
|
||||
// Keep existing emails visible while refreshing - swap atomically once
|
||||
// the new list is ready so pull-to-refresh doesn't flash an empty list.
|
||||
@@ -195,10 +226,50 @@ export const useEmailStore = create<EmailState>()(
|
||||
set({ emails, totalEmails: total, loading: false });
|
||||
} catch (err) {
|
||||
console.warn('[email-store] refreshEmails failed:', err);
|
||||
// If the list was empty before the refresh attempt, fall back to the
|
||||
// offline cache so the user still sees something. Otherwise keep what
|
||||
// they had visible and let the OfflineBanner explain.
|
||||
if (existing.length === 0) {
|
||||
try {
|
||||
const cacheStore = useOfflineCacheStore.getState();
|
||||
if (!cacheStore.hydrated) await cacheStore.hydrate();
|
||||
if (cacheStore.totalCount() > 0) {
|
||||
const limit = useSettingsStore.getState().emailsPerPage;
|
||||
const cached = await cacheStore.getEmailsInMailbox(
|
||||
currentMailboxId,
|
||||
Math.max(limit, 50),
|
||||
);
|
||||
if (get().currentMailboxId === currentMailboxId && cached.length > 0) {
|
||||
set({ emails: cached, totalEmails: cached.length, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (cacheErr) {
|
||||
console.warn('[email-store] refresh cache fallback failed:', cacheErr);
|
||||
}
|
||||
}
|
||||
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load emails' });
|
||||
}
|
||||
},
|
||||
|
||||
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();
|
||||
@@ -217,7 +288,27 @@ export const useEmailStore = create<EmailState>()(
|
||||
},
|
||||
|
||||
getEmailDetail: async (id) => {
|
||||
return getFullEmail(id);
|
||||
// Try the network first so the user sees fresh keywords/flags. If that
|
||||
// fails (offline / server unreachable), fall back to the offline cache
|
||||
// when the message is in it. Without the cache hit, propagate the error
|
||||
// so the caller can surface it.
|
||||
try {
|
||||
const fresh = await getFullEmail(id);
|
||||
// Opportunistically refresh the cached copy so the next offline open
|
||||
// reflects the latest keywords without needing a full sync.
|
||||
const cache = useOfflineCacheStore.getState();
|
||||
if (cache.has(id)) {
|
||||
try {
|
||||
const size = JSON.stringify(fresh).length;
|
||||
await cache.put(fresh, size);
|
||||
} catch { /* ignore — best-effort refresh */ }
|
||||
}
|
||||
return fresh;
|
||||
} catch (err) {
|
||||
const cached = await useOfflineCacheStore.getState().get(id);
|
||||
if (cached) return cached;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
markRead: async (emailId) => {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// Persistent cache for full email bodies (and optional attachment blobs)
|
||||
// powering offline mail viewing. Bodies are JSON-serialised and kept in
|
||||
// AsyncStorage; attachment blobs go to expo-file-system under
|
||||
// `Paths.document/offline-attachments/`. The store also exposes the live
|
||||
// progress state the OfflineCacheBanner reads from.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Email } from '../api/types';
|
||||
|
||||
const INDEX_KEY = 'webmail:offline-cache:index:v1';
|
||||
const ENTRY_KEY_PREFIX = 'webmail:offline-cache:entry:v1:';
|
||||
|
||||
function entryKey(id: string): string {
|
||||
return `${ENTRY_KEY_PREFIX}${id}`;
|
||||
}
|
||||
|
||||
export interface OfflineCacheIndexEntry {
|
||||
id: string;
|
||||
receivedAt: string;
|
||||
size: number; // approx bytes of cached payload
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
interface CacheIndex {
|
||||
// Per-account is keyed at the storage layer (one app session = one account
|
||||
// active at a time). Cross-account separation is left to a future change.
|
||||
entries: Record<string, OfflineCacheIndexEntry>;
|
||||
}
|
||||
|
||||
export type SyncPhase = 'idle' | 'scanning' | 'fetching' | 'done' | 'error' | 'cancelled';
|
||||
|
||||
export interface SyncState {
|
||||
phase: SyncPhase;
|
||||
total: number; // total emails the sync expects to process
|
||||
completed: number; // emails processed (skipped or fetched)
|
||||
fetched: number; // emails actually downloaded (not already cached)
|
||||
bytes: number; // bytes downloaded this run
|
||||
message?: string; // human-readable status (errors etc.)
|
||||
startedAt?: number;
|
||||
finishedAt?: number;
|
||||
}
|
||||
|
||||
const IDLE: SyncState = { phase: 'idle', total: 0, completed: 0, fetched: 0, bytes: 0 };
|
||||
|
||||
interface OfflineCacheState {
|
||||
index: CacheIndex;
|
||||
hydrated: boolean;
|
||||
sync: SyncState;
|
||||
// Allows the sync orchestrator to abort mid-run when the user disables the
|
||||
// setting or kicks off a fresh sync. Stored on the singleton because
|
||||
// AbortControllers need to survive across the function boundary.
|
||||
abortRequested: boolean;
|
||||
|
||||
hydrate: () => Promise<void>;
|
||||
has: (id: string) => boolean;
|
||||
get: (id: string) => Promise<Email | null>;
|
||||
put: (email: Email, approxBytes: number) => Promise<void>;
|
||||
remove: (ids: string[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
// Returns cached emails whose `mailboxIds` include the given mailbox,
|
||||
// sorted by `receivedAt` descending. Used by selectMailbox to seed the
|
||||
// list when the network is unavailable. Loads each entry from
|
||||
// AsyncStorage; the index doesn't carry the mailbox set.
|
||||
getEmailsInMailbox: (mailboxId: string, limit?: number) => Promise<Email[]>;
|
||||
|
||||
totalSize: () => number;
|
||||
totalCount: () => number;
|
||||
|
||||
setSyncState: (next: Partial<SyncState>) => void;
|
||||
resetSync: () => void;
|
||||
requestAbort: () => void;
|
||||
consumeAbort: () => boolean;
|
||||
}
|
||||
|
||||
function persistIndex(index: CacheIndex): void {
|
||||
void AsyncStorage.setItem(INDEX_KEY, JSON.stringify(index)).catch((err) => {
|
||||
console.warn('[offline-cache] persist index failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
export const useOfflineCacheStore = create<OfflineCacheState>((set, get) => ({
|
||||
index: { entries: {} },
|
||||
hydrated: false,
|
||||
sync: { ...IDLE },
|
||||
abortRequested: false,
|
||||
|
||||
hydrate: async () => {
|
||||
if (get().hydrated) return;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(INDEX_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as CacheIndex;
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
||||
set({ index: parsed, hydrated: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] hydrate failed', err);
|
||||
}
|
||||
set({ hydrated: true });
|
||||
},
|
||||
|
||||
has: (id) => Boolean(get().index.entries[id]),
|
||||
|
||||
get: async (id) => {
|
||||
if (!get().index.entries[id]) return null;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(entryKey(id));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as Email;
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] get failed', id, err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
put: async (email, approxBytes) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(entryKey(email.id), JSON.stringify(email));
|
||||
} catch (err) {
|
||||
console.warn('[offline-cache] put failed', email.id, err);
|
||||
return;
|
||||
}
|
||||
const nextEntries = {
|
||||
...get().index.entries,
|
||||
[email.id]: {
|
||||
id: email.id,
|
||||
receivedAt: email.receivedAt,
|
||||
size: approxBytes,
|
||||
cachedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
const index = { entries: nextEntries };
|
||||
set({ index });
|
||||
persistIndex(index);
|
||||
},
|
||||
|
||||
remove: async (ids) => {
|
||||
if (ids.length === 0) return;
|
||||
await Promise.all(
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
|
||||
);
|
||||
const next = { ...get().index.entries };
|
||||
for (const id of ids) delete next[id];
|
||||
const index = { entries: next };
|
||||
set({ index });
|
||||
persistIndex(index);
|
||||
},
|
||||
|
||||
clearAll: async () => {
|
||||
const ids = Object.keys(get().index.entries);
|
||||
await Promise.all(
|
||||
ids.map((id) => AsyncStorage.removeItem(entryKey(id)).catch(() => undefined)),
|
||||
);
|
||||
const index = { entries: {} };
|
||||
set({ index, sync: { ...IDLE } });
|
||||
persistIndex(index);
|
||||
},
|
||||
|
||||
getEmailsInMailbox: async (mailboxId, limit = 200) => {
|
||||
// Sort the index by receivedAt descending first so we don't have to read
|
||||
// entries we won't return — the index already carries receivedAt.
|
||||
const sorted = Object.values(get().index.entries).sort((a, b) => {
|
||||
const at = a.receivedAt ? new Date(a.receivedAt).getTime() : 0;
|
||||
const bt = b.receivedAt ? new Date(b.receivedAt).getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
const matches: Email[] = [];
|
||||
for (const entry of sorted) {
|
||||
if (matches.length >= limit) break;
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(entryKey(entry.id));
|
||||
if (!raw) continue;
|
||||
const email = JSON.parse(raw) as Email;
|
||||
if (email.mailboxIds?.[mailboxId]) {
|
||||
matches.push(email);
|
||||
}
|
||||
} catch {
|
||||
// skip corrupt entries — they'll be replaced on next sync
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
},
|
||||
|
||||
totalSize: () => {
|
||||
let n = 0;
|
||||
for (const e of Object.values(get().index.entries)) n += e.size;
|
||||
return n;
|
||||
},
|
||||
|
||||
totalCount: () => Object.keys(get().index.entries).length,
|
||||
|
||||
setSyncState: (next) => set({ sync: { ...get().sync, ...next } }),
|
||||
|
||||
resetSync: () => set({ sync: { ...IDLE }, abortRequested: false }),
|
||||
|
||||
requestAbort: () => set({ abortRequested: true }),
|
||||
|
||||
consumeAbort: () => {
|
||||
const v = get().abortRequested;
|
||||
if (v) set({ abortRequested: false });
|
||||
return v;
|
||||
},
|
||||
}));
|
||||
@@ -131,6 +131,13 @@ interface PersistedSettings {
|
||||
|
||||
// Plugins (UI-level enable map)
|
||||
pluginEnabled: Record<string, boolean>;
|
||||
|
||||
// Offline mail cache: download recent message bodies in the background so
|
||||
// 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;
|
||||
}
|
||||
|
||||
const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
@@ -207,6 +214,9 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
||||
filtersExpandedView: false,
|
||||
|
||||
pluginEnabled: {},
|
||||
|
||||
offlineCacheEnabled: false,
|
||||
offlineCacheDays: 7,
|
||||
};
|
||||
|
||||
export interface SettingsState extends PersistedSettings {
|
||||
|
||||
@@ -108,9 +108,15 @@ export const useUpdatesStore = create<UpdatesState>((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({
|
||||
|
||||
@@ -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,
|
||||
environment: 'node',
|
||||
include: ['src/**/__tests__/**/*.test.ts'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user