mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bdca0133e | ||
|
|
1858d08dd0 | ||
|
|
427d163dc0 | ||
|
|
7e34ba42ef | ||
|
|
886a64171d | ||
|
|
760bf01f4d | ||
|
|
b6d87093b9 | ||
|
|
2a3b1f90dc | ||
|
|
2ed9c29bd5 | ||
|
|
455f26e90a | ||
|
|
e9b93bc6e5 | ||
|
|
c21661ac7a | ||
|
|
00be851611 | ||
|
|
71ff8930a2 | ||
|
|
29b06a3a4f | ||
|
|
2911b8e7ac | ||
|
|
3d4779a689 | ||
|
|
641fde0505 | ||
|
|
6d5195d9fa |
@@ -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';
|
||||
|
||||
@@ -48,8 +51,21 @@ const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||
const navigationRef = createNavigationContainerRef<RootStackParamList>();
|
||||
|
||||
function navigateToNotificationTap(payload: NotificationTapPayload): void {
|
||||
async function navigateToNotificationTap(payload: NotificationTapPayload): Promise<void> {
|
||||
if (!navigationRef.isReady()) return;
|
||||
|
||||
// The notification carries the account it was generated for. If the user
|
||||
// has since switched to a different account (or had a different one active
|
||||
// when the notification arrived), opening EmailThread under the active
|
||||
// account would fetch the email from the wrong server and fail.
|
||||
const auth = useAuthStore.getState();
|
||||
if (payload.accountId && payload.accountId !== auth.activeAccountId) {
|
||||
const account = useAccountStore.getState().getAccountById(payload.accountId);
|
||||
if (!account) return; // account was logged out — nothing safe to open.
|
||||
await auth.switchAccount(payload.accountId);
|
||||
if (useAuthStore.getState().activeAccountId !== payload.accountId) return;
|
||||
}
|
||||
|
||||
navigationRef.navigate('EmailThread', {
|
||||
emailId: payload.emailId,
|
||||
threadId: payload.threadId,
|
||||
@@ -76,6 +92,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||
<UpdateBanner />
|
||||
<OfflineCacheBanner />
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
@@ -218,6 +235,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.
|
||||
@@ -239,11 +274,11 @@ export default function App() {
|
||||
void (async () => {
|
||||
const initial = await getInitialNotificationTap();
|
||||
if (cancelled || !initial) return;
|
||||
navigateToNotificationTap(initial);
|
||||
await navigateToNotificationTap(initial);
|
||||
})();
|
||||
|
||||
const unsubscribe = addNotificationTapListener((payload) => {
|
||||
navigateToNotificationTap(payload);
|
||||
void navigateToNotificationTap(payload);
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -11,16 +11,17 @@ 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 okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
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 java.util.concurrent.TimeUnit
|
||||
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
|
||||
@@ -36,10 +37,11 @@ import kotlin.concurrent.thread
|
||||
* 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 OkHttp that injects the picked
|
||||
* cert into the TLS handshake. JS-side `fetch` cannot present client
|
||||
* certs because RN's bundled OkHttpClient doesn't expose its KeyManager
|
||||
* hook to userland; this method is the workaround.
|
||||
* 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) {
|
||||
@@ -48,8 +50,8 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
@Volatile private var cachedAlias: String? = prefs.getString(PREF_ALIAS, null)
|
||||
@Volatile private var cachedClient: OkHttpClient? = null
|
||||
@Volatile private var cachedClientForAlias: String? = null
|
||||
@Volatile private var cachedFactory: SSLSocketFactory? = null
|
||||
@Volatile private var cachedFactoryForAlias: String? = null
|
||||
|
||||
override fun getName(): String = MODULE_NAME
|
||||
|
||||
@@ -64,32 +66,34 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
fun clearAlias(promise: Promise) {
|
||||
prefs.edit().remove(PREF_ALIAS).apply()
|
||||
cachedAlias = null
|
||||
invalidateClient()
|
||||
invalidateFactory()
|
||||
promise.resolve(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the system "Choose a certificate" picker. Resolves with the
|
||||
* selected alias, or `null` when the user dismisses the picker.
|
||||
*
|
||||
* @param host optional host hint shown by the picker
|
||||
*/
|
||||
@ReactMethod
|
||||
fun pickAlias(host: String?, promise: Promise) {
|
||||
val activity = currentActivity
|
||||
// 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. We persist + resolve directly.
|
||||
// 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
|
||||
invalidateClient()
|
||||
invalidateFactory()
|
||||
promise.resolve(alias)
|
||||
}
|
||||
try {
|
||||
@@ -125,65 +129,70 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
promise.reject("bad_args", "url is required")
|
||||
return
|
||||
}
|
||||
val method = request.getString("method") ?: "GET"
|
||||
val timeoutMs = if (request.hasKey("timeoutMs")) request.getInt("timeoutMs").toLong() else 30_000L
|
||||
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 contentType = headersMap
|
||||
?.let { h ->
|
||||
val it = h.keySetIterator()
|
||||
var ct: String? = null
|
||||
while (it.hasNextKey()) {
|
||||
val k = it.nextKey()
|
||||
if (k.equals("Content-Type", ignoreCase = true)) {
|
||||
ct = h.getString(k)
|
||||
break
|
||||
}
|
||||
}
|
||||
ct
|
||||
}
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
|
||||
// Run on a worker thread so we don't block the JS thread on socket IO.
|
||||
thread(name = "BulwarkClientCert.fetch") {
|
||||
try {
|
||||
val client = buildOrReuseClient(timeoutMs)
|
||||
val builder = Request.Builder().url(url)
|
||||
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) builder.addHeader(k, v)
|
||||
if (v != null) conn.setRequestProperty(k, v)
|
||||
}
|
||||
}
|
||||
val bodyBytes = bodyBase64?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
val body = when {
|
||||
bodyBytes != null -> bodyBytes.toRequestBody(contentType?.toMediaTypeOrNull())
|
||||
methodRequiresBody(method) -> ByteArray(0).toRequestBody(contentType?.toMediaTypeOrNull())
|
||||
else -> null
|
||||
conn.connect()
|
||||
if (bodyBytes != null) {
|
||||
conn.outputStream.use { out -> out.write(bodyBytes) }
|
||||
}
|
||||
builder.method(method.uppercase(), body)
|
||||
client.newCall(builder.build()).execute().use { response ->
|
||||
val out = Arguments.createMap()
|
||||
out.putInt("status", response.code)
|
||||
out.putString("statusText", response.message)
|
||||
val headers = Arguments.createMap()
|
||||
response.headers.forEach { (name, value) ->
|
||||
// OkHttp can return multiple values per header; we
|
||||
// join with ", " which is the standard wire format
|
||||
// for repeated header values.
|
||||
if (headers.hasKey(name)) {
|
||||
val existing = headers.getString(name)
|
||||
headers.putString(name, "$existing, $value")
|
||||
} else {
|
||||
headers.putString(name, value)
|
||||
}
|
||||
}
|
||||
out.putMap("headers", headers)
|
||||
val responseBytes = response.body?.bytes() ?: ByteArray(0)
|
||||
out.putString("bodyBase64", Base64.encodeToString(responseBytes, Base64.NO_WRAP))
|
||||
promise.resolve(out)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -192,49 +201,35 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
|
||||
// ── internals ───────────────────────────────────────────────
|
||||
|
||||
private fun invalidateClient() {
|
||||
cachedClient = null
|
||||
cachedClientForAlias = null
|
||||
private fun invalidateFactory() {
|
||||
cachedFactory = null
|
||||
cachedFactoryForAlias = null
|
||||
}
|
||||
|
||||
private fun buildOrReuseClient(timeoutMs: Long): OkHttpClient {
|
||||
val alias = cachedAlias
|
||||
val existing = cachedClient
|
||||
if (existing != null && cachedClientForAlias == alias) return existing
|
||||
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 builder = OkHttpClient.Builder()
|
||||
.callTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.connectTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
if (alias != null) {
|
||||
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)
|
||||
builder.sslSocketFactory(sslContext.socketFactory, tm)
|
||||
}
|
||||
|
||||
val client = builder.build()
|
||||
cachedClient = client
|
||||
cachedClientForAlias = alias
|
||||
return client
|
||||
}
|
||||
|
||||
private fun methodRequiresBody(method: String): Boolean = when (method.uppercase()) {
|
||||
"POST", "PUT", "PATCH", "DELETE" -> true
|
||||
else -> false
|
||||
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 on the OkHttp dispatcher thread, which is fine since the
|
||||
* them lazily during the TLS handshake, which is fine since the
|
||||
* fetch already runs off-main-thread.
|
||||
*/
|
||||
private class KeyChainKeyManager(
|
||||
@@ -251,13 +246,13 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
override fun chooseEngineClientAlias(
|
||||
keyType: Array<out String>?,
|
||||
issuers: Array<out Principal>?,
|
||||
engine: javax.net.ssl.SSLEngine?,
|
||||
engine: SSLEngine?,
|
||||
): String = alias
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
return try {
|
||||
KeyChain.getCertificateChain(context, this.alias)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -265,7 +260,7 @@ class BulwarkClientCertModule(reactContext: ReactApplicationContext)
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
return try {
|
||||
KeyChain.getPrivateKey(context, this.alias)
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
@@ -64,6 +65,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
val emailId = options.getString("emailId")
|
||||
val threadId = options.getString("threadId")
|
||||
val subject = options.takeIf { it.hasKey("subject") }?.getString("subject")
|
||||
val accountId = options.takeIf { it.hasKey("accountId") }?.getString("accountId")
|
||||
|
||||
// Bitmap fetch + draw off the bridge thread so the caller doesn't
|
||||
// block waiting for the favicon request.
|
||||
@@ -72,7 +74,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
?: makeLetterAvatar(initials, bgColorHex)
|
||||
postNotification(
|
||||
notificationId, title, body, largeIcon, bgColorHex,
|
||||
emailId, threadId, subject,
|
||||
emailId, threadId, subject, accountId,
|
||||
)
|
||||
promise.resolve(null)
|
||||
}
|
||||
@@ -93,6 +95,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
emailId: String?,
|
||||
threadId: String?,
|
||||
subject: String?,
|
||||
accountId: String?,
|
||||
) {
|
||||
val ctx = reactApplicationContext
|
||||
val intent = Intent(ctx, MainActivity::class.java).apply {
|
||||
@@ -100,6 +103,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
|
||||
if (emailId != null) putExtra(NotificationTapStore.EXTRA_EMAIL_ID, emailId)
|
||||
if (threadId != null) putExtra(NotificationTapStore.EXTRA_THREAD_ID, threadId)
|
||||
if (subject != null) putExtra(NotificationTapStore.EXTRA_SUBJECT, subject)
|
||||
if (accountId != null) putExtra(NotificationTapStore.EXTRA_ACCOUNT_ID, accountId)
|
||||
}
|
||||
val pending = PendingIntent.getActivity(
|
||||
ctx,
|
||||
@@ -148,7 +152,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 +200,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?) {
|
||||
|
||||
@@ -17,17 +17,36 @@ 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}$")
|
||||
// accountId is `username@host` (see generateAccountId), so it needs `.`,
|
||||
// `@`, `+` etc. — cap length and restrict charset to what that helper can
|
||||
// emit. accountId is optional: notifications created before this field
|
||||
// existed still navigate (using the active account as a fallback).
|
||||
private val ACCOUNT_ID_PATTERN = Regex("^[A-Za-z0-9._+@-]{1,256}$")
|
||||
|
||||
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 payload = TapPayload(emailId, threadId, 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 accountId = extras.getString(EXTRA_ACCOUNT_ID)?.takeIf { ACCOUNT_ID_PATTERN.matches(it) }
|
||||
val payload = TapPayload(emailId, threadId, subject, accountId)
|
||||
pending = payload
|
||||
// Clear so a subsequent activity lifecycle event doesn't replay this.
|
||||
extras.remove(EXTRA_EMAIL_ID)
|
||||
extras.remove(EXTRA_THREAD_ID)
|
||||
extras.remove(EXTRA_SUBJECT)
|
||||
extras.remove(EXTRA_ACCOUNT_ID)
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -35,15 +54,18 @@ object NotificationTapStore {
|
||||
val emailId: String,
|
||||
val threadId: String,
|
||||
val subject: String?,
|
||||
val accountId: String?,
|
||||
) {
|
||||
fun toMap(): WritableMap = Arguments.createMap().apply {
|
||||
putString("emailId", emailId)
|
||||
putString("threadId", threadId)
|
||||
if (subject != null) putString("subject", subject)
|
||||
if (accountId != null) putString("accountId", accountId)
|
||||
}
|
||||
}
|
||||
|
||||
const val EXTRA_EMAIL_ID = "bulwark.notification.emailId"
|
||||
const val EXTRA_THREAD_ID = "bulwark.notification.threadId"
|
||||
const val EXTRA_SUBJECT = "bulwark.notification.subject"
|
||||
const val EXTRA_ACCOUNT_ID = "bulwark.notification.accountId"
|
||||
}
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.29",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.29",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -5216,6 +5217,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-document-picker": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.29",
|
||||
"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"
|
||||
},
|
||||
@@ -22,6 +23,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -61,6 +61,57 @@ export async function uploadBlob(
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
// Direct in-memory upload. The on-disk variant goes through expo-file-system's
|
||||
// `new File(uri).bytes()`, which is overkill when we already have the bytes
|
||||
// (e.g. the empty-blob trick used to create directories on Stalwart).
|
||||
export async function uploadBytes(
|
||||
bytes: Uint8Array,
|
||||
type: string,
|
||||
): Promise<{ blobId: string; size: number; type: string }> {
|
||||
const session = jmapClient.currentSession;
|
||||
if (!session) throw new Error('Not connected');
|
||||
|
||||
const uploadUrl = session.uploadUrl.replace(
|
||||
'{accountId}',
|
||||
encodeURIComponent(jmapClient.accountId),
|
||||
);
|
||||
|
||||
const response = await secureFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': type || 'application/octet-stream',
|
||||
Authorization: jmapClient.authHeader,
|
||||
},
|
||||
body: bytes.buffer as ArrayBuffer,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '');
|
||||
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
const raw = (await response.json()) as Record<string, unknown>;
|
||||
const direct = raw as { blobId?: string; type?: string; size?: number };
|
||||
if (typeof direct.blobId === 'string') {
|
||||
return {
|
||||
blobId: direct.blobId,
|
||||
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
|
||||
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
|
||||
};
|
||||
}
|
||||
const nested = raw[jmapClient.accountId] as
|
||||
| { blobId?: string; type?: string; size?: number }
|
||||
| undefined;
|
||||
if (nested?.blobId) {
|
||||
return {
|
||||
blobId: nested.blobId,
|
||||
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
|
||||
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
|
||||
};
|
||||
}
|
||||
throw new Error('Upload succeeded but response did not include a blobId');
|
||||
}
|
||||
|
||||
export function getDownloadUrl(
|
||||
blobId: string,
|
||||
name?: string,
|
||||
|
||||
+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(
|
||||
|
||||
+157
-8
@@ -1,19 +1,68 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode } from './types';
|
||||
import { getDownloadUrl, uploadBlob, uploadBytes } from './blob';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
|
||||
|
||||
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
|
||||
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
|
||||
// mark a directory; everything else is a MIME type.
|
||||
const DIRECTORY_TYPES = new Set([
|
||||
'd',
|
||||
'application/x-directory',
|
||||
'text/directory',
|
||||
'httpd/unix-directory',
|
||||
'inode/directory',
|
||||
]);
|
||||
|
||||
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
|
||||
const t = node.type;
|
||||
if (!t) return false;
|
||||
return DIRECTORY_TYPES.has(t) || t.includes('directory');
|
||||
}
|
||||
|
||||
// Stalwart rejects `/` in FileNode names, so the webmail (and now this app)
|
||||
// uses U+2215 DIVISION SLASH as the path separator inside the flat `name`
|
||||
// field. Hierarchy is encoded into the name — there is no `parentId` nesting.
|
||||
// e.g. a file inside the `Documents` folder is stored with
|
||||
// `name = "Documents∕report.pdf"`. The display name shown to the user
|
||||
// strips the prefix.
|
||||
export const PATH_SEP = '∕';
|
||||
|
||||
// Path is a list of folder display names from root. [] is root,
|
||||
// ['Documents'] is inside Documents, ['Documents','sub'] is inside the
|
||||
// `sub` folder under Documents.
|
||||
export function pathToPrefix(path: readonly string[]): string {
|
||||
if (path.length === 0) return '';
|
||||
return path.join(PATH_SEP) + PATH_SEP;
|
||||
}
|
||||
|
||||
// True when `node` is a direct child (not a descendant) of `prefix`.
|
||||
export function isDirectChildOfPrefix(node: Pick<FileNode, 'name'>, prefix: string): boolean {
|
||||
if (!node.name) return false;
|
||||
if (prefix === '') return !node.name.includes(PATH_SEP);
|
||||
if (!node.name.startsWith(prefix)) return false;
|
||||
const rest = node.name.slice(prefix.length);
|
||||
return rest.length > 0 && !rest.includes(PATH_SEP);
|
||||
}
|
||||
|
||||
export function nodeDisplayName(node: Pick<FileNode, 'name'>, prefix: string): string {
|
||||
return prefix && node.name.startsWith(prefix) ? node.name.slice(prefix.length) : node.name;
|
||||
}
|
||||
|
||||
// Stalwart's FileNode/get does not support a parentId filter and some builds
|
||||
// omit `type` from the default property set. We fetch the whole tree and
|
||||
// filter client-side; properties are listed explicitly so `isDirectory()`
|
||||
// always has a value to look at.
|
||||
const FILE_NODE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'updated'];
|
||||
|
||||
export async function getAllFileNodes(): Promise<FileNode[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (parentId !== undefined) filter.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, filter }, '0']],
|
||||
[['FileNode/get', { accountId, ids: null, properties: FILE_NODE_PROPERTIES }, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
return (res.methodResponses[0][1].list ?? []) as FileNode[];
|
||||
}
|
||||
|
||||
export async function createFileNode(
|
||||
@@ -27,13 +76,113 @@ export async function createFileNode(
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-file'];
|
||||
const created = res.methodResponses[0][1].created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = res.methodResponses[0][1].notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Create failed');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
displayName: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
// Stalwart's FileNode/set rejects directory creates without a blobId
|
||||
// ("Missing blob id"), so we upload an empty blob first and reference it.
|
||||
// Mirrors the webmail's createFileDirectory.
|
||||
const blob = await uploadBytes(new Uint8Array(0), 'application/x-directory');
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({ name: fullName, type: 'd', blobId: blob.blobId, size: 0 });
|
||||
}
|
||||
|
||||
export async function updateFileNode(
|
||||
id: string,
|
||||
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
|
||||
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
|
||||
}
|
||||
|
||||
// Rename a node and, if it's a folder, cascade the rename to every descendant
|
||||
// so the encoded path stays consistent. The webmail does this same dance.
|
||||
export async function renameFileNode(
|
||||
node: FileNode,
|
||||
newDisplayName: string,
|
||||
currentPath: readonly string[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const prefix = pathToPrefix(currentPath);
|
||||
const oldServerName = node.name;
|
||||
const newServerName = prefix + newDisplayName;
|
||||
if (oldServerName === newServerName) return;
|
||||
|
||||
await updateFileNode(node.id, { name: newServerName });
|
||||
|
||||
if (isDirectory(node)) {
|
||||
const oldFolderPrefix = oldServerName + PATH_SEP;
|
||||
const newFolderPrefix = newServerName + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.id === node.id) continue;
|
||||
if (n.name.startsWith(oldFolderPrefix)) {
|
||||
await updateFileNode(n.id, { name: newFolderPrefix + n.name.slice(oldFolderPrefix.length) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['FileNode/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete `targets` and every descendant. Stalwart doesn't enforce parent
|
||||
// references, so we collect descendants by name-prefix and destroy them all
|
||||
// in one call. Without this, deleting a folder leaves its files orphaned and
|
||||
// still discoverable at the old encoded path.
|
||||
export async function deleteWithDescendants(
|
||||
targets: readonly FileNode[],
|
||||
allNodes: readonly FileNode[],
|
||||
): Promise<void> {
|
||||
const ids = new Set<string>();
|
||||
for (const node of targets) {
|
||||
ids.add(node.id);
|
||||
if (isDirectory(node)) {
|
||||
const folderPrefix = node.name + PATH_SEP;
|
||||
for (const n of allNodes) {
|
||||
if (n.name.startsWith(folderPrefix)) ids.add(n.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await deleteFileNodes(Array.from(ids));
|
||||
}
|
||||
|
||||
export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
if (!node.blobId) throw new Error('Folders cannot be downloaded');
|
||||
return getDownloadUrl(node.blobId, node.name, node.type);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
displayName: string,
|
||||
mimeType: string,
|
||||
currentPath: readonly string[],
|
||||
): Promise<FileNode> {
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
const fullName = pathToPrefix(currentPath) + displayName;
|
||||
return createFileNode({
|
||||
name: fullName,
|
||||
type: blob.type || mimeType || 'application/octet-stream',
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
});
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -429,7 +429,7 @@ export interface FileNode {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type: string;
|
||||
blobId?: string;
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
@@ -465,4 +465,5 @@ export const CAPABILITIES = {
|
||||
CALENDARS: 'urn:ietf:params:jmap:calendars',
|
||||
SIEVE: 'urn:ietf:params:jmap:sieve',
|
||||
QUOTA: 'urn:ietf:params:jmap:quota',
|
||||
FILES: 'urn:ietf:params:jmap:filenode',
|
||||
} as const;
|
||||
|
||||
@@ -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,143 @@
|
||||
// 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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { CloudDownload, X } from 'lucide-react-native';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import { useUpdatesStore } from '../stores/updates-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 insets = useSafeAreaInsets();
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
|
||||
const resetSync = useOfflineCacheStore((s) => s.resetSync);
|
||||
// When UpdateBanner is stacked above us it already absorbs the status-bar
|
||||
// inset, so we only add it when we're the topmost banner.
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
const updateBannerVisible =
|
||||
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
|
||||
const topInset = updateBannerVisible ? 0 : insets.top;
|
||||
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, { paddingTop: spacing.sm + topInset }]}>
|
||||
<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>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
@@ -41,6 +41,7 @@ interface ShowNotificationOptions {
|
||||
emailId: string;
|
||||
threadId: string;
|
||||
subject?: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface BulwarkFcmNative {
|
||||
@@ -100,6 +101,7 @@ export async function pushBackgroundTask(_data: unknown): Promise<void> {
|
||||
emailId,
|
||||
threadId: email.threadId,
|
||||
subject: email.subject ?? undefined,
|
||||
accountId,
|
||||
});
|
||||
|
||||
await AsyncStorage.setItem(LAST_NOTIFIED_EMAIL_ID_KEY, emailId);
|
||||
|
||||
@@ -327,6 +327,10 @@ export interface NotificationTapPayload {
|
||||
emailId: string;
|
||||
threadId: string;
|
||||
subject?: string;
|
||||
// Identifies which logged-in account the notification was generated for.
|
||||
// Optional for back-compat: older notifications already on the system tray
|
||||
// won't carry this and will fall back to the active account on tap.
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
// Returns - and clears - any pending "notification tap" that launched the app
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+897
-142
File diff suppressed because it is too large
Load Diff
@@ -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,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