From 2e40240fe75c3314bdf255c2c0a1b4cc88e2cdc7 Mon Sep 17 00:00:00 2001 From: edwardfavour998-code Date: Thu, 27 Aug 2026 09:56:10 +0000 Subject: [PATCH] feat: widget size variants, vault selection, per-instance pinning, concurrent cache tests (#244 #245 #246 #247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #247 iOS: TTLWidgetView adapts layout across .systemSmall/.systemMedium/.systemLarge/.accessoryRectangular/.accessoryCircular - #247 Android: vault_widget_small/medium/large.xml layouts; VaultStatusWidget picks layout by width; onAppWidgetOptionsChanged re-renders on resize - #245 iOS: VaultSelectionIntent (AppIntents/WidgetConfigurationIntent) replaces StaticConfiguration; urgency fallback when vaultID is empty - #245 Android: VaultWidgetConfigActivity lets user pick a vault per widget instance; vault_id_list saved by worker for picker; urgency fallback on cancel - #246 iOS: AppIntentConfiguration gives each widget instance its own VaultSelectionIntent automatically - #246 Android: per-widget SharedPreferences (vault_widget_prefs_{id}); VaultWidgetUpdateWorker updates each widget independently - #244 Android: OfflineCacheTest concurrent read/write test; OfflineCache save/load protected by ReentrantReadWriteLock - #244 iOS: OfflineCacheConcurrencyTests (concurrent write+read, two-instance isolation); OfflineCache.save/load/delete/clearAll serialised via concurrent DispatchQueue with barrier writes - Updated PARITY.md: widget urgency selection now ✅ on both platforms - Updated .gitignore: exclude snapshot images, build artefacts, generated Xcode project --- .gitignore | 26 +- PARITY.md | 8 +- android/.gitignore | 1 + android/app/src/main/AndroidManifest.xml | 11 + .../com/ethosprotocol/api/Infrastructure.kt | 39 ++- .../ethosprotocol/widget/VaultStatusWidget.kt | 154 +++++++++-- .../widget/VaultWidgetConfigActivity.kt | 118 +++++++++ .../main/res/layout/vault_widget_large.xml | 51 ++++ .../main/res/layout/vault_widget_medium.xml | 48 ++++ .../main/res/layout/vault_widget_small.xml | 47 ++++ android/app/src/main/res/values/strings.xml | 5 + .../src/main/res/xml/vault_widget_info.xml | 5 +- .../com/ethosprotocol/OfflineCacheTest.kt | 32 +++ .../VaultWidgetUpdateWorkerTest.kt | 64 ++--- .../Sources/Services/OfflineSupport.swift | 47 ++-- .../Sources/Widget/TTLWidget.swift | 245 +++++++++++++++--- .../Tests/OfflineSupportTests.swift | 80 ++++++ 17 files changed, 862 insertions(+), 119 deletions(-) create mode 100644 android/app/src/main/java/com/ethosprotocol/widget/VaultWidgetConfigActivity.kt create mode 100644 android/app/src/main/res/layout/vault_widget_large.xml create mode 100644 android/app/src/main/res/layout/vault_widget_medium.xml create mode 100644 android/app/src/main/res/layout/vault_widget_small.xml 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..