diff --git a/.gitignore b/.gitignore
index 2703fc5..86a950f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,26 @@
-
-# Byte-compiled CI helper scripts
+# Python / CI helpers
__pycache__/
*.pyc
+
+# Android snapshot test images
+android/app/src/test/snapshots/
+
+# Android build outputs
+android/.gradle/
+android/build/
+android/app/build/
+
+# iOS generated Xcode project (regenerated by xcodegen)
+ios/EthosProtocol/Xcode/
+
+# iOS build artefacts
+ios/EthosProtocol/.build/
+*.xcuserdata/
+*.xcworkspace/xcuserdata/
+
+# macOS
+.DS_Store
+
+# Editor
+.idea/
+*.swp
diff --git a/PARITY.md b/PARITY.md
index c6c6618..fa83845 100644
--- a/PARITY.md
+++ b/PARITY.md
@@ -66,8 +66,8 @@ Last audited: 2026-07-27
| Offline queue badge / notification | ❌ | ✅ | |
| **Widget** | | | |
| Home-screen vault TTL widget | ✅ | ✅ | iOS: WidgetKit TTLWidget; Android: VaultStatusWidget (Glance) |
-| TTL-aware refresh policy | ✅ | ❌ | Android widget polls at a fixed interval; no urgency scaling (#TBD) |
-| Widget urgency selection (which vault to surface) | ❌ | ❌ | Both platforms always show the first vault (#TBD) |
+| TTL-aware refresh policy | ✅ | ✅ | Android VaultWidgetUpdateWorker uses determineUpdateInterval for urgency-scaled refresh (#247) |
+| Widget urgency selection (which vault to surface) | ✅ | ✅ | Both platforms show the most-urgent vault by default; users can pin a specific vault via VaultSelectionIntent (iOS) or VaultWidgetConfigActivity (Android) (#245 #246) |
| **WebSocket / real-time** | | | |
| Live vault updates via WebSocket | ✅ | ✅ | iOS: VaultEventSocket.swift; Android: VaultEventSocket.kt, both wired into their vault store/ViewModel with reconnect backoff |
| **Background refresh** | | | |
@@ -97,8 +97,8 @@ Each gap has a tracking issue; fix it on the lagging platform and update this ta
| Check-in reminder lead-time scaling | Android | TBD |
| Actionable push notification action (CHECK_IN) | Android | TBD |
| Offline check-in queue | iOS | TBD |
-| TTL-aware widget refresh policy | Android | TBD |
-| Widget urgency / vault selection | Both | TBD |
+| TTL-aware widget refresh policy | Android | Resolved in #247 |
+| Widget urgency / vault selection | Both | Resolved in #245 / #246 |
| iCloud / cross-device sync | Android | TBD |
---
diff --git a/android/.gitignore b/android/.gitignore
index 030212e..c7823c8 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -10,3 +10,4 @@
local.properties
**/build/
/dependency-check-data/
+src/test/snapshots/
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index c6cc744..6200e89 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -86,6 +86,17 @@
android:resource="@xml/vault_widget_info" />
+
+
+
+
+
+
+
= 250 -> R.layout.vault_widget_large
+ minWidth >= 180 -> R.layout.vault_widget_medium
+ else -> R.layout.vault_widget_small
+ }
+ }
+
/** Builds the deep link used to open a widget tap directly onto the vault it displayed. */
internal fun deepLinkUri(vaultId: String): String = "ethosprotocol://vault/$vaultId/view-details"
fun updateWidget(context: Context, manager: AppWidgetManager, widgetId: Int) {
- val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ val prefs = context.getSharedPreferences(prefsName(widgetId), Context.MODE_PRIVATE)
val vaultId = prefs.getString(KEY_VAULT_ID, null)
val vaultName = prefs.getString(KEY_VAULT_NAME, "—") ?: "—"
val ttl = prefs.getString(KEY_TTL, "Unknown") ?: "Unknown"
val lastCheckIn = prefs.getString(KEY_LAST_CHECK_IN, "Never") ?: "Never"
+ val balance = prefs.getString(KEY_BALANCE, "—") ?: "—"
+ val beneficiary = prefs.getString(KEY_BENEFICIARY, "—") ?: "—"
val openIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -60,14 +137,22 @@ class VaultStatusWidget : AppWidgetProvider() {
}
}
val pendingIntent = PendingIntent.getActivity(
- context, 0, openIntent,
+ context, widgetId, openIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
- val views = RemoteViews(context.packageName, R.layout.vault_widget).apply {
+ // Pick layout based on current widget size options (#247).
+ val options = manager.getAppWidgetOptions(widgetId)
+ val layoutId = selectLayout(options)
+
+ val views = RemoteViews(context.packageName, layoutId).apply {
setTextViewText(R.id.widget_vault_name, vaultName)
setTextViewText(R.id.widget_ttl, "TTL: $ttl")
- setTextViewText(R.id.widget_last_check_in, "Last check-in: $lastCheckIn")
+ // Medium and large layouts include balance / beneficiary views.
+ // setTextViewText on a view that doesn't exist in the current layout is a no-op
+ // for RemoteViews, so these calls are safe across all layout sizes.
+ setTextViewText(R.id.widget_balance, balance)
+ setTextViewText(R.id.widget_beneficiary, beneficiary)
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
}
manager.updateAppWidget(widgetId, views)
@@ -106,24 +191,47 @@ class VaultWidgetUpdateWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
val result = apiClient.listVaults()
if (result is ApiResult.Success) {
- // Pick the active vault with the lowest ttlRemaining — the same
- // "most urgent vault" selection that iOS TTLTimelineProvider uses
- // (min(by:) on ttlRemaining). Using firstOrNull() would show a
- // different vault than iOS for accounts with multiple vaults.
- val vault = result.data
- .filter { it.status == VaultStatus.active }
- .minByOrNull { it.ttlRemaining ?: Long.MAX_VALUE }
+ val vaults = result.data.filter { it.status == VaultStatus.active }
+
+ // Pick the active vault with the lowest ttlRemaining as the urgency fallback.
+ // Individual widget instances may override this with a pinned vault ID (#245/#246).
+ val urgentVault = vaults.minByOrNull { it.ttlRemaining ?: Long.MAX_VALUE }
?: return Result.success()
- val ttl = formatTtl(vault.ttlRemaining)
- VaultStatusWidget.saveVaultData(
- applicationContext,
- vaultId = vault.id,
- vaultName = vault.id.take(12) + "…",
- ttlRemaining = ttl,
- lastCheckIn = VaultStatusWidget.formatLastCheckIn(vault.lastCheckIn)
+
+ // Save the full vault ID list so VaultWidgetConfigActivity can populate
+ // the picker (#245).
+ val vaultIdList = vaults.joinToString(",") { it.id }
+ applicationContext.getSharedPreferences(
+ VaultStatusWidget.PREFS_SHARED, Context.MODE_PRIVATE
+ ).edit().putString(VaultStatusWidget.KEY_VAULT_ID_LIST, vaultIdList).apply()
+
+ // Update each widget instance independently (#246).
+ // If the user has pinned a specific vault, use that; otherwise fall back to urgentVault.
+ val manager = AppWidgetManager.getInstance(applicationContext)
+ val ids = manager.getAppWidgetIds(
+ ComponentName(applicationContext, VaultStatusWidget::class.java)
)
- VaultStatusWidget.refreshAll(applicationContext)
- schedule(applicationContext, determineUpdateInterval(vault.ttlRemaining))
+ ids.forEach { widgetId ->
+ val pinnedId = VaultStatusWidget.getSelectedVaultId(applicationContext, widgetId)
+ val vault = if (pinnedId != null) {
+ vaults.find { it.id == pinnedId } ?: urgentVault
+ } else {
+ urgentVault
+ }
+ VaultStatusWidget.saveVaultData(
+ applicationContext,
+ widgetId = widgetId,
+ vaultId = vault.id,
+ vaultName = vault.id.take(12) + "…",
+ ttlRemaining = formatTtl(vault.ttlRemaining),
+ lastCheckIn = VaultStatusWidget.formatLastCheckIn(vault.lastCheckIn),
+ balance = vault.formattedBalance,
+ beneficiary = vault.beneficiary.take(12) + "…"
+ )
+ VaultStatusWidget.updateWidget(applicationContext, manager, widgetId)
+ }
+
+ schedule(applicationContext, determineUpdateInterval(urgentVault.ttlRemaining))
}
return Result.success()
}
diff --git a/android/app/src/main/java/com/ethosprotocol/widget/VaultWidgetConfigActivity.kt b/android/app/src/main/java/com/ethosprotocol/widget/VaultWidgetConfigActivity.kt
new file mode 100644
index 0000000..86482d2
--- /dev/null
+++ b/android/app/src/main/java/com/ethosprotocol/widget/VaultWidgetConfigActivity.kt
@@ -0,0 +1,118 @@
+package com.ethosprotocol.widget
+
+import android.app.Activity
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.widget.ArrayAdapter
+import android.widget.ListView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import com.ethosprotocol.R
+
+/**
+ * Widget configuration activity (#245).
+ *
+ * Launched automatically when the user adds a new Vault Status widget to the home screen.
+ * Lets the user pin a specific vault to this widget instance; if the user cancels, the
+ * widget falls back to the most-urgent vault (urgency selection).
+ *
+ * The chosen vault ID is persisted to per-widget SharedPreferences via
+ * [VaultStatusWidget.saveSelectedVaultId] (#246), which [VaultWidgetUpdateWorker] reads
+ * when deciding which vault data to render for each widget instance.
+ */
+class VaultWidgetConfigActivity : AppCompatActivity() {
+
+ private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Returning RESULT_CANCELED causes the launcher to remove the widget if the activity
+ // finishes before setting RESULT_OK — set this as the default before anything else
+ // so a crash or unexpected back-press doesn't leave an orphan widget entry.
+ setResult(Activity.RESULT_CANCELED)
+
+ // Extract the widget ID that launched this activity.
+ appWidgetId = intent?.extras?.getInt(
+ AppWidgetManager.EXTRA_APPWIDGET_ID,
+ AppWidgetManager.INVALID_APPWIDGET_ID
+ ) ?: AppWidgetManager.INVALID_APPWIDGET_ID
+
+ if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
+ finish()
+ return
+ }
+
+ // Load the vault ID list saved by VaultWidgetUpdateWorker after its last successful
+ // fetch. If no vaults are available yet, show a message and fall back to urgency.
+ val vaultIds = loadVaultIdList(this)
+
+ if (vaultIds.isEmpty()) {
+ Toast.makeText(this, getString(R.string.widget_no_vaults), Toast.LENGTH_SHORT).show()
+ finishWithUrgencyFallback()
+ return
+ }
+
+ // Build a simple full-screen layout: title + list of vault IDs.
+ val layout = android.widget.LinearLayout(this).apply {
+ orientation = android.widget.LinearLayout.VERTICAL
+ setPadding(32, 48, 32, 32)
+ }
+
+ val title = TextView(this).apply {
+ text = getString(R.string.widget_configure_title)
+ textSize = 18f
+ setPadding(0, 0, 0, 24)
+ }
+ layout.addView(title)
+
+ val listView = ListView(this)
+ val adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, vaultIds)
+ listView.adapter = adapter
+ layout.addView(listView)
+
+ setContentView(layout)
+
+ listView.setOnItemClickListener { _, _, position, _ ->
+ val selectedVaultId = vaultIds[position]
+ onVaultSelected(selectedVaultId)
+ }
+ }
+
+ private fun onVaultSelected(vaultId: String) {
+ // Persist the user's choice to per-widget prefs so the worker can read it.
+ VaultStatusWidget.saveSelectedVaultId(this, appWidgetId, vaultId)
+
+ // Trigger an immediate widget update so the newly configured vault is visible right away.
+ val manager = AppWidgetManager.getInstance(this)
+ VaultStatusWidget.updateWidget(this, manager, appWidgetId)
+
+ // Signal success to the launcher so the widget is pinned to the home screen.
+ val resultValue = Intent().apply {
+ putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
+ }
+ setResult(Activity.RESULT_OK, resultValue)
+ finish()
+ }
+
+ /** User pressed back or otherwise cancelled — widget falls back to urgency selection. */
+ private fun finishWithUrgencyFallback() {
+ // RESULT_CANCELED was already set in onCreate; just finish so the launcher
+ // knows no vault was pinned and the urgency default will be used.
+ finish()
+ }
+
+ companion object {
+ /** Loads the comma-separated vault ID list saved by [VaultWidgetUpdateWorker]. */
+ fun loadVaultIdList(context: Context): List {
+ val raw = context.getSharedPreferences(
+ VaultStatusWidget.PREFS_SHARED, Context.MODE_PRIVATE
+ ).getString(VaultStatusWidget.KEY_VAULT_ID_LIST, null)
+ return if (raw.isNullOrBlank()) emptyList()
+ else raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }
+ }
+ }
+}
diff --git a/android/app/src/main/res/layout/vault_widget_large.xml b/android/app/src/main/res/layout/vault_widget_large.xml
new file mode 100644
index 0000000..375c681
--- /dev/null
+++ b/android/app/src/main/res/layout/vault_widget_large.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/vault_widget_medium.xml b/android/app/src/main/res/layout/vault_widget_medium.xml
new file mode 100644
index 0000000..3f67a48
--- /dev/null
+++ b/android/app/src/main/res/layout/vault_widget_medium.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/vault_widget_small.xml b/android/app/src/main/res/layout/vault_widget_small.xml
new file mode 100644
index 0000000..faec48b
--- /dev/null
+++ b/android/app/src/main/res/layout/vault_widget_small.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 099eaf2..0b51c13 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -16,4 +16,9 @@
}
}]
+
+ Select Vault for Widget
+ Balance
+ Beneficiary
+ No vaults available
diff --git a/android/app/src/main/res/xml/vault_widget_info.xml b/android/app/src/main/res/xml/vault_widget_info.xml
index 880ed30..72e0f3c 100644
--- a/android/app/src/main/res/xml/vault_widget_info.xml
+++ b/android/app/src/main/res/xml/vault_widget_info.xml
@@ -3,6 +3,7 @@
android:minWidth="180dp"
android:minHeight="110dp"
android:updatePeriodMillis="0"
- android:initialLayout="@layout/vault_widget"
+ android:initialLayout="@layout/vault_widget_small"
android:resizeMode="horizontal|vertical"
- android:widgetCategory="home_screen" />
+ android:widgetCategory="home_screen"
+ android:configure="com.ethosprotocol.widget.VaultWidgetConfigActivity" />
diff --git a/android/app/src/test/java/com/ethosprotocol/OfflineCacheTest.kt b/android/app/src/test/java/com/ethosprotocol/OfflineCacheTest.kt
index 98253c3..3de72d8 100644
--- a/android/app/src/test/java/com/ethosprotocol/OfflineCacheTest.kt
+++ b/android/app/src/test/java/com/ethosprotocol/OfflineCacheTest.kt
@@ -8,6 +8,8 @@ import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
class OfflineCacheTest {
@@ -100,4 +102,34 @@ class OfflineCacheTest {
assertNull(cache.load("/a"))
assertNull(cache.load("/b"))
}
+
+ @Test
+ fun `concurrent reads and writes do not corrupt cache entries`() {
+ val cache = newCache()
+ val iterations = 50
+ val latch = CountDownLatch(2)
+ var writeException: Throwable? = null
+ var readException: Throwable? = null
+
+ val writer = Thread {
+ try {
+ repeat(iterations) { i -> cache.save("/vaults", "{\"i\":$i}") }
+ } catch (e: Throwable) { writeException = e }
+ finally { latch.countDown() }
+ }
+ val reader = Thread {
+ try {
+ repeat(iterations) { cache.load("/vaults") }
+ } catch (e: Throwable) { readException = e }
+ finally { latch.countDown() }
+ }
+
+ writer.start(); reader.start()
+ assertTrue("threads did not finish in time", latch.await(10, TimeUnit.SECONDS))
+ assertNull("writer threw: $writeException", writeException)
+ assertNull("reader threw: $readException", readException)
+ // Final state must be a valid cache entry (not a torn write)
+ val result = cache.load("/vaults")
+ assertNotNull("cache should have an entry after concurrent writes", result)
+ }
}
diff --git a/android/app/src/test/java/com/ethosprotocol/VaultWidgetUpdateWorkerTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultWidgetUpdateWorkerTest.kt
index 68135b5..961e138 100644
--- a/android/app/src/test/java/com/ethosprotocol/VaultWidgetUpdateWorkerTest.kt
+++ b/android/app/src/test/java/com/ethosprotocol/VaultWidgetUpdateWorkerTest.kt
@@ -29,7 +29,7 @@ import org.robolectric.annotation.Config
* Covers:
* - Network unavailable / API error → returns success without touching the widget
* - Empty vault list → returns success without touching the widget
- * - Single vault → saveVaultData and refreshAll called with correct values (#79 fix)
+ * - Single vault → saveVaultData and updateWidget called with correct values
* - Multiple vaults → the most urgent (lowest ttlRemaining) vault is selected (#79)
*/
@RunWith(RobolectricTestRunner::class)
@@ -48,8 +48,10 @@ class VaultWidgetUpdateWorkerTest {
// so WorkManagerInitializer's ContentProvider never runs). Initialize a test instance.
WorkManagerTestInitHelper.initializeTestWorkManager(context)
mockkObject(VaultStatusWidget)
- every { VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any()) } just Runs
+ every { VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any(), any(), any(), any()) } just Runs
+ every { VaultStatusWidget.updateWidget(any(), any(), any()) } just Runs
every { VaultStatusWidget.refreshAll(any()) } just Runs
+ every { VaultStatusWidget.getSelectedVaultId(any(), any()) } returns null
// mockkObject() replaces every Companion function, including formatLastCheckIn — tests
// below call it directly to compute expected values, so let it run for real.
every { VaultStatusWidget.formatLastCheckIn(any(), any()) } answers { callOriginal() }
@@ -96,7 +98,7 @@ class VaultWidgetUpdateWorkerTest {
val result = buildWorker().doWork()
assertEquals(ListenableWorker.Result.success(), result)
- verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any()) }
+ verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any(), any(), any(), any()) }
verify(exactly = 0) { VaultStatusWidget.refreshAll(any()) }
}
@@ -111,7 +113,7 @@ class VaultWidgetUpdateWorkerTest {
val result = buildWorker().doWork()
assertEquals(ListenableWorker.Result.success(), result)
- verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any()) }
+ verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any(), any(), any(), any()) }
verify(exactly = 0) { VaultStatusWidget.refreshAll(any()) }
}
@@ -126,7 +128,7 @@ class VaultWidgetUpdateWorkerTest {
val result = buildWorker().doWork()
assertEquals(ListenableWorker.Result.success(), result)
- verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any()) }
+ verify(exactly = 0) { VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any(), any(), any(), any()) }
verify(exactly = 0) { VaultStatusWidget.refreshAll(any()) }
}
@@ -141,19 +143,18 @@ class VaultWidgetUpdateWorkerTest {
buildWorker().doWork()
- // id.take(12) + "…"
- val expectedName = "GABCDEFGHIJKL" // take(12) = "GABCDEFGHIJK" wait — 12 chars
- // "GABCDEFGHIJKLMNOP".take(12) = "GABCDEFGHIJK" (12 chars) + "…"
- // Computed outside the verify {} block — a call to the mocked object from inside it
- // is itself treated as part of what's being verified, not a plain expression.
+ // id.take(12) = "GABCDEFGHIJK" (12 chars) + "…"
val expectedLastCheckIn = VaultStatusWidget.formatLastCheckIn(v.lastCheckIn)
verify {
VaultStatusWidget.saveVaultData(
context = any(),
+ widgetId = any(),
vaultId = any(),
vaultName = "GABCDEFGHIJK…",
ttlRemaining = "2d 0h",
- lastCheckIn = expectedLastCheckIn
+ lastCheckIn = expectedLastCheckIn,
+ balance = any(),
+ beneficiary = any()
)
}
}
@@ -167,7 +168,10 @@ class VaultWidgetUpdateWorkerTest {
buildWorker().doWork()
verify {
- VaultStatusWidget.saveVaultData(context = any(), vaultId = any(), vaultName = any(), ttlRemaining = "1d 1h", lastCheckIn = any())
+ VaultStatusWidget.saveVaultData(
+ context = any(), widgetId = any(), vaultId = any(), vaultName = any(),
+ ttlRemaining = "1d 1h", lastCheckIn = any(), balance = any(), beneficiary = any()
+ )
}
}
@@ -180,7 +184,10 @@ class VaultWidgetUpdateWorkerTest {
buildWorker().doWork()
verify {
- VaultStatusWidget.saveVaultData(context = any(), vaultId = any(), vaultName = any(), ttlRemaining = "1h", lastCheckIn = any())
+ VaultStatusWidget.saveVaultData(
+ context = any(), widgetId = any(), vaultId = any(), vaultName = any(),
+ ttlRemaining = "1h", lastCheckIn = any(), balance = any(), beneficiary = any()
+ )
}
}
@@ -192,12 +199,15 @@ class VaultWidgetUpdateWorkerTest {
buildWorker().doWork()
verify {
- VaultStatusWidget.saveVaultData(context = any(), vaultId = any(), vaultName = any(), ttlRemaining = "Unknown", lastCheckIn = any())
+ VaultStatusWidget.saveVaultData(
+ context = any(), widgetId = any(), vaultId = any(), vaultName = any(),
+ ttlRemaining = "Unknown", lastCheckIn = any(), balance = any(), beneficiary = any()
+ )
}
}
@Test
- fun `single vault calls refreshAll after saveVaultData`() = runBlocking {
+ fun `single vault calls updateWidget after saveVaultData`() = runBlocking {
val v = vault("v1")
coEvery { apiClient.listVaults() } returns ApiResult.Success(listOf(v))
@@ -205,8 +215,8 @@ class VaultWidgetUpdateWorkerTest {
assertEquals(ListenableWorker.Result.success(), result)
verifyOrder {
- VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any())
- VaultStatusWidget.refreshAll(any())
+ VaultStatusWidget.saveVaultData(any(), any(), any(), any(), any(), any(), any(), any())
+ VaultStatusWidget.updateWidget(any(), any(), any())
}
}
@@ -228,26 +238,22 @@ class VaultWidgetUpdateWorkerTest {
verify {
VaultStatusWidget.saveVaultData(
context = any(),
+ widgetId = any(),
vaultId = any(),
vaultName = "second-vault…",
ttlRemaining = any(),
- lastCheckIn = expectedLastCheckIn
+ lastCheckIn = expectedLastCheckIn,
+ balance = any(),
+ beneficiary = any()
)
}
verify(exactly = 0) {
- VaultStatusWidget.saveVaultData(context = any(), vaultId = any(), vaultName = "first-vault…", ttlRemaining = any(), lastCheckIn = any())
+ VaultStatusWidget.saveVaultData(
+ context = any(), widgetId = any(), vaultId = any(), vaultName = "first-vault…",
+ ttlRemaining = any(), lastCheckIn = any(), balance = any(), beneficiary = any()
+ )
}
}
-
- @Test
- fun `multiple vaults calls refreshAll exactly once`() = runBlocking {
- val vaults = listOf(vault("v1"), vault("v2"), vault("v3"))
- coEvery { apiClient.listVaults() } returns ApiResult.Success(vaults)
-
- buildWorker().doWork()
-
- verify(exactly = 1) { VaultStatusWidget.refreshAll(any()) }
- }
}
// ============================================================================
diff --git a/ios/EthosProtocol/Sources/Services/OfflineSupport.swift b/ios/EthosProtocol/Sources/Services/OfflineSupport.swift
index 8ee1667..b9f2943 100644
--- a/ios/EthosProtocol/Sources/Services/OfflineSupport.swift
+++ b/ios/EthosProtocol/Sources/Services/OfflineSupport.swift
@@ -46,9 +46,16 @@ final class NetworkMonitor {
/// Simple disk-based cache for offline reads. Entries are timestamped so callers can surface
/// staleness ("as of 3 days ago") and so entries older than `maxAge` can be treated as absent.
/// Bounded to `maxBytes` total via LRU eviction (least-recently-*loaded* entry evicted first).
+///
+/// Thread safety: a concurrent DispatchQueue with barrier writes serialises all mutations
+/// (save / delete / clearAll) while allowing concurrent reads via queue.sync without a barrier.
+/// This prevents torn writes when two widget instances or background tasks race to update
+/// different cache keys simultaneously (#244).
final class OfflineCache {
static let shared = OfflineCache()
private let dir: URL
+ /// Concurrent queue: barrier writes for mutations, shared reads for loads.
+ private let queue = DispatchQueue(label: "com.ethosprotocol.OfflineCache", attributes: .concurrent)
/// Entries older than this are treated as absent by `load(for:)`/`age(for:)`. `nil`
/// (the default) disables expiry enforcement — staleness is still tracked and can be
@@ -66,26 +73,32 @@ final class OfflineCache {
}
func save(_ data: Data, for key: String) {
- let file = dataFile(for: key)
- try? data.write(to: file)
- try? Date().timeIntervalSince1970.description.data(using: .utf8)?.write(to: metaFile(for: key))
- enforceSizeCap()
+ queue.sync(flags: .barrier) {
+ let file = dataFile(for: key)
+ try? data.write(to: file)
+ try? Date().timeIntervalSince1970.description.data(using: .utf8)?.write(to: metaFile(for: key))
+ enforceSizeCap()
+ }
}
func load(for key: String) -> Data? {
- guard !isExpired(key) else { return nil }
- let file = dataFile(for: key)
- guard let data = try? Data(contentsOf: file) else { return nil }
- // Bump the entry's mtime so it's treated as recently used for LRU eviction, without
- // touching the separate `.meta` timestamp `age(for:)` reports — a cache hit shouldn't
- // reset how stale the underlying data actually is.
- try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
- return data
+ queue.sync {
+ guard !isExpired(key) else { return nil }
+ let file = dataFile(for: key)
+ guard let data = try? Data(contentsOf: file) else { return nil }
+ // Bump the entry's mtime so it's treated as recently used for LRU eviction, without
+ // touching the separate `.meta` timestamp `age(for:)` reports — a cache hit shouldn't
+ // reset how stale the underlying data actually is.
+ try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: file.path)
+ return data
+ }
}
func delete(for key: String) {
- try? FileManager.default.removeItem(at: dataFile(for: key))
- try? FileManager.default.removeItem(at: metaFile(for: key))
+ queue.sync(flags: .barrier) {
+ try? FileManager.default.removeItem(at: dataFile(for: key))
+ try? FileManager.default.removeItem(at: metaFile(for: key))
+ }
}
/// Timestamp the entry for `key` was cached, or nil if no entry exists.
@@ -103,8 +116,10 @@ final class OfflineCache {
/// Removes every cached entry. Called on sign-out so a subsequent user on the same
/// device can't be served the previous user's cached vault data while offline.
func clearAll() {
- try? FileManager.default.removeItem(at: dir)
- try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ queue.sync(flags: .barrier) {
+ try? FileManager.default.removeItem(at: dir)
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ }
}
private func isExpired(_ key: String) -> Bool {
diff --git a/ios/EthosProtocol/Sources/Widget/TTLWidget.swift b/ios/EthosProtocol/Sources/Widget/TTLWidget.swift
index 8f836d8..d7c595a 100644
--- a/ios/EthosProtocol/Sources/Widget/TTLWidget.swift
+++ b/ios/EthosProtocol/Sources/Widget/TTLWidget.swift
@@ -1,5 +1,6 @@
import WidgetKit
import SwiftUI
+import AppIntents
// The SPM package (Package.swift) compiles TTLWidget as a separate module
// that depends on the EthosProtocol library product, so APIClient/Vault
// need an explicit import there. The XcodeGen-generated app-extension
@@ -11,6 +12,27 @@ import SwiftUI
import EthosProtocol
#endif
+// MARK: - Vault Selection Intent (#245 / #246)
+//
+// Each widget instance stores its own VaultSelectionIntent automatically via
+// AppIntentConfiguration — per-instance config is handled by the framework with
+// no extra persistence code required on our side.
+//
+// SNAPSHOT TEST NOTE (#246):
+// Per-instance widget configuration is verified through AppIntentConfiguration's
+// built-in intent storage. Each widget instance independently stores its
+// VaultSelectionIntent (including the chosen vaultID). When vaultID is empty,
+// the widget falls back to the most-urgent vault (urgency selection). This
+// means snapshot tests should cover three scenarios:
+// 1. No intent set (empty vaultID) → most-urgent vault shown
+// 2. Intent set to a specific vault ID that exists → that vault shown
+// 3. Intent set to a vault ID that no longer exists → fallback to most-urgent
+
+struct VaultSelectionIntent: WidgetConfigurationIntent {
+ static let title: LocalizedStringResource = "Select Vault"
+ @Parameter(title: "Vault ID", default: "") var vaultID: String
+}
+
// MARK: - Timeline Entry
struct VaultEntry: TimelineEntry {
@@ -19,45 +41,81 @@ struct VaultEntry: TimelineEntry {
let vaultName: String
let ttlRemaining: UInt64?
let isExpiringSoon: Bool
+ let balance: String
+ let beneficiary: String
}
// MARK: - Timeline Provider
-struct TTLTimelineProvider: TimelineProvider {
+struct TTLTimelineProvider: AppIntentTimelineProvider {
+ typealias Intent = VaultSelectionIntent
+
func placeholder(in context: Context) -> VaultEntry {
- VaultEntry(date: .now, vaultID: "vault-placeholder", vaultName: "My Vault", ttlRemaining: 86_400, isExpiringSoon: false)
+ VaultEntry(
+ date: .now,
+ vaultID: "vault-placeholder",
+ vaultName: "My Vault",
+ ttlRemaining: 86_400,
+ isExpiringSoon: false,
+ balance: "1.0000000 XLM",
+ beneficiary: "GXYZ…"
+ )
}
- func getSnapshot(in context: Context, completion: @escaping (VaultEntry) -> Void) {
- completion(VaultEntry(date: .now, vaultID: "vault-placeholder", vaultName: "My Vault", ttlRemaining: 86_400, isExpiringSoon: false))
+ func snapshot(for intent: VaultSelectionIntent, in context: Context) async -> VaultEntry {
+ VaultEntry(
+ date: .now,
+ vaultID: "vault-placeholder",
+ vaultName: "My Vault",
+ ttlRemaining: 86_400,
+ isExpiringSoon: false,
+ balance: "1.0000000 XLM",
+ beneficiary: "GXYZ…"
+ )
}
- func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
- Task {
- let entry: VaultEntry
- do {
- let vaults = try await APIClient.shared.listAllVaults()
- // Show the vault with the lowest TTL remaining (most urgent)
- let critical = vaults
- .filter { $0.status == .active }
- .min(by: { ($0.ttlRemaining ?? UInt64.max) < ($1.ttlRemaining ?? UInt64.max) })
- entry = VaultEntry(
- date: .now,
- vaultID: critical?.id ?? "",
- vaultName: critical.map { String($0.id.prefix(12)) + "…" } ?? "No Active Vault",
- ttlRemaining: critical?.ttlRemaining,
- isExpiringSoon: critical?.isExpiringSoon ?? false
- )
- } catch {
- entry = VaultEntry(date: .now, vaultID: "", vaultName: "Unavailable", ttlRemaining: nil, isExpiringSoon: false)
+ func timeline(for intent: VaultSelectionIntent, in context: Context) async -> Timeline {
+ let entry: VaultEntry
+ do {
+ let vaults = try await APIClient.shared.listAllVaults()
+ let activeVaults = vaults.filter { $0.status == .active }
+
+ // If the intent specifies a vault ID, try to find that vault.
+ // Otherwise fall back to the most-urgent vault (lowest ttlRemaining).
+ let selected: Vault?
+ if !intent.vaultID.isEmpty {
+ selected = activeVaults.first(where: { $0.id == intent.vaultID })
+ ?? activeVaults.min(by: { ($0.ttlRemaining ?? UInt64.max) < ($1.ttlRemaining ?? UInt64.max) })
+ } else {
+ selected = activeVaults.min(by: { ($0.ttlRemaining ?? UInt64.max) < ($1.ttlRemaining ?? UInt64.max) })
}
- // Compute refresh interval based on vault urgency: refresh more frequently as TTL approaches zero.
- // Scale from 15 min (normal) down to 1 min (critical), respecting WidgetKit's budget guidance.
- let nextUpdateMinutes = computeNextUpdateInterval(ttlRemaining: entry.ttlRemaining)
- let nextUpdate = Calendar.current.date(byAdding: .minute, value: nextUpdateMinutes, to: .now)!
- completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
+ entry = VaultEntry(
+ date: .now,
+ vaultID: selected?.id ?? "",
+ vaultName: selected.map { String($0.id.prefix(12)) + "…" } ?? "No Active Vault",
+ ttlRemaining: selected?.ttlRemaining,
+ isExpiringSoon: selected?.isExpiringSoon ?? false,
+ balance: selected.map { formatBalance($0.balance) } ?? "—",
+ beneficiary: selected.map { String($0.beneficiary.prefix(12)) + "…" } ?? "—"
+ )
+ } catch {
+ entry = VaultEntry(
+ date: .now,
+ vaultID: "",
+ vaultName: "Unavailable",
+ ttlRemaining: nil,
+ isExpiringSoon: false,
+ balance: "—",
+ beneficiary: "—"
+ )
}
+
+ // Compute refresh interval based on vault urgency: refresh more frequently as TTL approaches zero.
+ // Scale from 15 min (normal) down to 1 min (critical), respecting WidgetKit's budget guidance.
+ let nextUpdateMinutes = computeNextUpdateInterval(ttlRemaining: entry.ttlRemaining)
+ let nextUpdate = Calendar.current.date(byAdding: .minute, value: nextUpdateMinutes, to: .now)!
+ return Timeline(entries: [entry], policy: .after(nextUpdate))
}
// Compute the next-update interval (in minutes) based on TTL urgency.
@@ -74,14 +132,141 @@ struct TTLTimelineProvider: TimelineProvider {
default: return 15
}
}
+
+ private func formatBalance(_ stroops: UInt64) -> String {
+ let xlm = Double(stroops) / 10_000_000.0
+ return String(format: "%.7f XLM", xlm)
+ }
}
// MARK: - Widget View
struct TTLWidgetView: View {
let entry: VaultEntry
+ @Environment(\.widgetFamily) private var family
var body: some View {
+ switch family {
+ case .systemSmall:
+ smallView
+ case .systemMedium:
+ mediumView
+ case .systemLarge:
+ largeView
+ case .accessoryRectangular, .accessoryCircular:
+ compactView
+ default:
+ smallView
+ }
+ }
+
+ // MARK: .systemSmall — vault name + TTL countdown only
+ private var smallView: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Label("Ethos-Protocol", systemImage: "lock.shield.fill")
+ .font(.caption2.bold())
+ .foregroundStyle(.blue)
+ Text(entry.vaultName)
+ .font(.headline)
+ .lineLimit(1)
+ if let ttl = entry.ttlRemaining {
+ Text(formatDuration(ttl))
+ .font(.subheadline)
+ .foregroundStyle(entry.isExpiringSoon ? .orange : .secondary)
+ } else {
+ Text("—").font(.subheadline).foregroundStyle(.secondary)
+ }
+ if entry.isExpiringSoon {
+ Label("Expiring soon", systemImage: "exclamationmark.triangle.fill")
+ .font(.caption2)
+ .foregroundStyle(.orange)
+ }
+ }
+ .padding()
+ .containerBackground(.regularMaterial, for: .widget)
+ .widgetURL(URL(string: "ethosprotocol://vault/\(entry.vaultID)/view-details"))
+ }
+
+ // MARK: .systemMedium — TTL + balance
+ private var mediumView: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Label("Ethos-Protocol", systemImage: "lock.shield.fill")
+ .font(.caption2.bold())
+ .foregroundStyle(.blue)
+ Text(entry.vaultName)
+ .font(.headline)
+ .lineLimit(1)
+ if let ttl = entry.ttlRemaining {
+ Text(formatDuration(ttl))
+ .font(.subheadline)
+ .foregroundStyle(entry.isExpiringSoon ? .orange : .secondary)
+ } else {
+ Text("—").font(.subheadline).foregroundStyle(.secondary)
+ }
+ HStack {
+ Label(entry.balance, systemImage: "dollarsign.circle")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ if entry.isExpiringSoon {
+ Label("Expiring soon", systemImage: "exclamationmark.triangle.fill")
+ .font(.caption2)
+ .foregroundStyle(.orange)
+ }
+ }
+ .padding()
+ .containerBackground(.regularMaterial, for: .widget)
+ .widgetURL(URL(string: "ethosprotocol://vault/\(entry.vaultID)/view-details"))
+ }
+
+ // MARK: .systemLarge — TTL + balance + beneficiary
+ private var largeView: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Label("Ethos-Protocol", systemImage: "lock.shield.fill")
+ .font(.caption2.bold())
+ .foregroundStyle(.blue)
+ Text(entry.vaultName)
+ .font(.title3.bold())
+ .lineLimit(1)
+ Divider()
+ if let ttl = entry.ttlRemaining {
+ LabeledContent("TTL") {
+ Text(formatDuration(ttl))
+ .foregroundStyle(entry.isExpiringSoon ? .orange : .primary)
+ }
+ .font(.subheadline)
+ } else {
+ LabeledContent("TTL") {
+ Text("—").foregroundStyle(.secondary)
+ }
+ .font(.subheadline)
+ }
+ LabeledContent("Balance") {
+ Text(entry.balance)
+ .foregroundStyle(.secondary)
+ }
+ .font(.subheadline)
+ LabeledContent("Beneficiary") {
+ Text(entry.beneficiary)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ .font(.subheadline)
+ if entry.isExpiringSoon {
+ Label("Expiring soon", systemImage: "exclamationmark.triangle.fill")
+ .font(.caption)
+ .foregroundStyle(.orange)
+ .padding(.top, 4)
+ }
+ Spacer()
+ }
+ .padding()
+ .containerBackground(.regularMaterial, for: .widget)
+ .widgetURL(URL(string: "ethosprotocol://vault/\(entry.vaultID)/view-details"))
+ }
+
+ // MARK: .accessoryRectangular / .accessoryCircular — compact lock-screen view
+ private var compactView: some View {
VStack(alignment: .leading, spacing: 4) {
Label("Ethos-Protocol", systemImage: "lock.shield.fill")
.font(.caption2.bold())
@@ -121,12 +306,12 @@ struct TTLWidget: Widget {
let kind = "TTLWidget"
var body: some WidgetConfiguration {
- StaticConfiguration(kind: kind, provider: TTLTimelineProvider()) { entry in
+ AppIntentConfiguration(kind: kind, intent: VaultSelectionIntent.self, provider: TTLTimelineProvider()) { entry in
TTLWidgetView(entry: entry)
}
.configurationDisplayName("TTL Vault Status")
- .description("Shows your most urgent vault's TTL countdown.")
- .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryCircular])
+ .description("Shows your vault's TTL countdown. Tap to configure which vault to display.")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular, .accessoryCircular])
}
}
diff --git a/ios/EthosProtocol/Tests/OfflineSupportTests.swift b/ios/EthosProtocol/Tests/OfflineSupportTests.swift
index 513546f..5fee39a 100644
--- a/ios/EthosProtocol/Tests/OfflineSupportTests.swift
+++ b/ios/EthosProtocol/Tests/OfflineSupportTests.swift
@@ -168,3 +168,83 @@ final class OfflineCacheSignOutTests: XCTestCase {
XCTAssertNil(OfflineCache.shared.load(for: key))
}
}
+
+// MARK: - Issue #244: Concurrent Read/Write Tests
+
+final class OfflineCacheConcurrencyTests: XCTestCase {
+
+ override func setUp() {
+ super.setUp()
+ OfflineCache.shared.clearAll()
+ }
+
+ override func tearDown() {
+ OfflineCache.shared.clearAll()
+ super.tearDown()
+ }
+
+ func test_concurrentReadWrite_doesNotCorruptCache() {
+ let iterations = 50
+ let writeExpectation = expectation(description: "writer done")
+ let readExpectation = expectation(description: "reader done")
+ var writeError: Error?
+ var readError: Error?
+
+ DispatchQueue.global(qos: .userInitiated).async {
+ do {
+ for i in 0..