diff --git a/mobile-app/android/app/src/androidTest/kotlin/com/example/resonance_network_wallet/SwapLiveTimeoutInstrumentedTest.kt b/mobile-app/android/app/src/androidTest/kotlin/com/example/resonance_network_wallet/SwapLiveTimeoutInstrumentedTest.kt new file mode 100644 index 000000000..16ad7d760 --- /dev/null +++ b/mobile-app/android/app/src/androidTest/kotlin/com/example/resonance_network_wallet/SwapLiveTimeoutInstrumentedTest.kt @@ -0,0 +1,67 @@ +package com.example.resonance_network_wallet + +import android.os.Build +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/// The system removes a finished swap card on its own. A swap still moving keeps its card. +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) +class SwapLiveTimeoutInstrumentedTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun finishedCardHasTimeout_inProgressDoesNot() { + val now = System.currentTimeMillis() + + val swapped = SwapLiveNotifications.build(context, card("swapped", null, now, holdsProcess = false)) + assertWithin(SWAP_LIVE_SWAPPED_WINDOW_MS, swapped.timeoutAfter) + + val refunded = SwapLiveNotifications.build(context, card("failed", "refunded", now, holdsProcess = false)) + assertWithin(SWAP_LIVE_FAILED_WINDOW_MS, refunded.timeoutAfter) + + val incomplete = SwapLiveNotifications.build(context, card("failed", "incomplete", now, holdsProcess = false)) + assertEquals(0L, incomplete.timeoutAfter) + + val settling = SwapLiveNotifications.build(context, card("settling", null, now, holdsProcess = true)) + assertEquals(0L, settling.timeoutAfter) + + // A refresh 14 minutes after the finish carries a fresh updatedAt. The deadline stays put. + val redraw = card("swapped", null, now, holdsProcess = false, finishedAt = now - 14 * 60_000L) + assertWithin(60_000L, SwapLiveNotifications.build(context, redraw).timeoutAfter) + } + + private fun assertWithin(window: Long, timeout: Long) { + // The card is built a moment after `now`, so a little of the window is already gone. + assertTrue("timeout $timeout for window $window", timeout in (window - 5_000L)..window) + } + + /// [finishedAt] is what [SwapLiveNotifications.show] stores on a finished card before drawing it. + private fun card( + phase: String, + failure: String?, + updatedAt: Long, + holdsProcess: Boolean, + finishedAt: Long? = if (swapLiveTerminal(phase, failure)) updatedAt else null, + ): JSONObject = + JSONObject() + .put("key", "swap-a") + .put("phase", phase) + .put("failure", failure ?: JSONObject.NULL) + .put("brand", "Quantus") + .put("idleBrand", "Quantus") + .put("headline", "Swap") + .put("progress", if (holdsProcess) 0.75 else 1.0) + .put("showProgress", holdsProcess) + .put("holdsProcess", holdsProcess) + .put("updatedAt", updatedAt) + .put("staleAt", updatedAt + 120_000L) + .put("endsAt", JSONObject.NULL) + .put("finishedAt", finishedAt ?: JSONObject.NULL) +} diff --git a/mobile-app/android/app/src/main/kotlin/com/example/resonance_network_wallet/SwapLiveNotifications.kt b/mobile-app/android/app/src/main/kotlin/com/example/resonance_network_wallet/SwapLiveNotifications.kt index b16224d9b..f04e3de1d 100644 --- a/mobile-app/android/app/src/main/kotlin/com/example/resonance_network_wallet/SwapLiveNotifications.kt +++ b/mobile-app/android/app/src/main/kotlin/com/example/resonance_network_wallet/SwapLiveNotifications.kt @@ -38,6 +38,9 @@ object SwapLiveNotifications { private const val PREFS = "swap_live_activity" private const val PREF_PAYLOAD = "payload" private const val PREF_DISMISSED = "dismissed_key" + + /// The first finished write's updatedAt for the stored swap. Set by [show], never by Dart or a push. + private const val FINISHED_AT = "finishedAt" private const val TAG = "SwapLive" private const val METHOD_CHANNEL = "app.quantus/swap_live_activity" private const val EVENT_CHANNEL = "app.quantus/swap_live_activity_events" @@ -173,6 +176,16 @@ object SwapLiveNotifications { val card = if (stale) json.put("stale", true) else json ensureChannel(context, card.optString("channelName", "Swap progress")) val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val stored = storedSwapCard(prefs) + val finishedAt = swapLiveFinishedAtMs( + key = card.optString("key"), + phase = card.optString("phase"), + failure = if (card.isNull("failure")) null else textOrNull(card.opt("failure")), + updatedAtMs = if (card.isNull("updatedAt")) null else epochOf(card.opt("updatedAt")), + storedKey = stored.key, + storedFinishedAtMs = stored.finishedAt, + ) + if (finishedAt == null) card.remove(FINISHED_AT) else card.put(FINISHED_AT, finishedAt) val editor = prefs.edit().putString(PREF_PAYLOAD, card.toString()) val dismissed = prefs.getString(PREF_DISMISSED, null) val key = card.optString("key") @@ -339,6 +352,18 @@ object SwapLiveNotifications { .setOngoing(json.optBoolean("holdsProcess") && !stale) .setContentIntent(openApp(context)) .setDeleteIntent(broadcast(context, ACTION_DISMISSED, 2)) + // A finished card is never ongoing, so only this builder draws one. The + // system removes it on time even with the process gone, and the removal + // goes through the delete intent like a swipe. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val phase = json.optString("phase") + val failure = if (json.isNull("failure")) null else textOrNull(json.opt("failure")) + val finishedAt = if (json.isNull(FINISHED_AT)) null else epochOf(json.opt(FINISHED_AT)) + if (swapLiveTerminal(phase, failure) && finishedAt == null) { + Log.w(TAG, "A finished swap card has no $FINISHED_AT, so it has no timeout") + } + swapLiveTimeoutMs(phase, failure, finishedAt, System.currentTimeMillis())?.let(builder::setTimeoutAfter) + } if (Build.VERSION.SDK_INT >= 36 && showProgress) { builder.setStyle(Notification.ProgressStyle().setProgress((json.optDouble("progress") * 100).toInt())) } @@ -399,7 +424,8 @@ object SwapLiveNotifications { private fun scheduleStale(context: Context, json: JSONObject) { val staleAt = if (json.isNull("staleAt")) 0L else json.optLong("staleAt", 0L) - if (staleAt <= 0L) { + val failure = if (json.isNull("failure")) null else textOrNull(json.opt("failure")) + if (staleAt <= 0L || !swapLiveSchedulesStale(json.optString("phase"), failure)) { cancelStale(context) return } @@ -428,6 +454,7 @@ object SwapLiveNotifications { val updatedAt: Long?, val phase: String?, val failure: String?, + val finishedAt: Long?, ) { val revision = SwapLiveCardRevision(updatedAt, phase, failure) } @@ -436,15 +463,16 @@ object SwapLiveNotifications { private fun storedSwapCard(prefs: android.content.SharedPreferences): StoredSwapCard { val dismissed = prefs.getString(PREF_DISMISSED, null) - val raw = prefs.getString(PREF_PAYLOAD, null) ?: return StoredSwapCard(null, dismissed, null, null, null) + val raw = prefs.getString(PREF_PAYLOAD, null) ?: return StoredSwapCard(null, dismissed, null, null, null, null) val json = runCatching { JSONObject(raw) }.getOrNull() - if (json == null) return StoredSwapCard(null, dismissed, null, null, null) + if (json == null) return StoredSwapCard(null, dismissed, null, null, null, null) return StoredSwapCard( json.optString("key").takeIf { it.isNotEmpty() }, dismissed, if (json.isNull("updatedAt")) null else epochOf(json.opt("updatedAt")), json.optString("phase").takeIf { it.isNotEmpty() }, if (json.isNull("failure")) null else textOrNull(json.opt("failure")), + if (json.isNull(FINISHED_AT)) null else epochOf(json.opt(FINISHED_AT)), ) } @@ -627,6 +655,41 @@ internal fun swapLiveTerminal(phase: String?, failure: String?): Boolean { return phase == "failed" && failure != "incomplete" } +/// A finished card has nothing left to check, so it never switches to the +/// stale layout before its timeout removes it. Same rule as iOS `swapLiveStaleDate`. +internal fun swapLiveSchedulesStale(phase: String?, failure: String?): Boolean = !swapLiveTerminal(phase, failure) + +/// How long a finished card stays after the swap finished. Same windows as iOS +/// `swapLiveDismissalDate` and the end push Senoti sends. +internal const val SWAP_LIVE_SWAPPED_WINDOW_MS = 15 * 60_000L +internal const val SWAP_LIVE_FAILED_WINDOW_MS = 30 * 60_000L + +/// Time left before a finished card leaves. Counted from [swapLiveFinishedAtMs], +/// so redrawing the card does not push the removal back. Null while the swap +/// is still open, including an incomplete deposit. A card past its window gets +/// the shortest timeout, because zero means none. +internal fun swapLiveTimeoutMs(phase: String?, failure: String?, finishedAtMs: Long?, nowMs: Long): Long? { + if (!swapLiveTerminal(phase, failure) || finishedAtMs == null) return null + val window = if (phase == "swapped") SWAP_LIVE_SWAPPED_WINDOW_MS else SWAP_LIVE_FAILED_WINDOW_MS + return (finishedAtMs + window - nowMs).coerceAtLeast(1L) +} + +/// When the swap on a finished card finished. The first finished write for a +/// swap fixes it. Every later refresh carries a newer updatedAt, which orders +/// writes but must not move the deadline. Null while the swap is still open. +internal fun swapLiveFinishedAtMs( + key: String, + phase: String?, + failure: String?, + updatedAtMs: Long?, + storedKey: String?, + storedFinishedAtMs: Long?, +): Long? { + if (!swapLiveTerminal(phase, failure)) return null + if (key == storedKey && storedFinishedAtMs != null) return storedFinishedAtMs + return updatedAtMs +} + /// A local write may start a card, or replace one with a different swap. /// The swap already on screen follows [swapLiveRevisionAllows]. The swap the /// user dismissed stays dismissed until a different swap starts. diff --git a/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveFinishedAtTest.kt b/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveFinishedAtTest.kt new file mode 100644 index 000000000..a68e01045 --- /dev/null +++ b/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveFinishedAtTest.kt @@ -0,0 +1,65 @@ +package com.example.resonance_network_wallet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/// The app redraws a finished card with a fresh updatedAt every refresh. The +/// card still leaves on the deadline set by its first finished write. +class SwapLiveFinishedAtTest { + private val settledAt = minutes(6 * 60) + + @Test + fun theFirstFinishedWriteFixesTheFinishTime() { + assertEquals(settledAt, finishedAt("swap-a", "swapped", null, updatedAt = settledAt, stored = null)) + } + + @Test + fun aRegeneratedFinishedPayloadKeepsTheFirstDeadline() { + // Settled at 06:00. The 06:14 refresh rebuilds the payload with updatedAt 06:14. + val redraw = settledAt + minutes(14) + val kept = finishedAt("swap-a", "swapped", null, updatedAt = redraw, stored = "swap-a" to settledAt) + + assertEquals(settledAt, kept) + assertEquals(minutes(1), swapLiveTimeoutMs("swapped", null, finishedAtMs = kept, nowMs = redraw)) + } + + @Test + fun aRegeneratedRefundKeepsTheFirstDeadline() { + val redraw = settledAt + minutes(20) + val kept = finishedAt("swap-a", "failed", "refunded", updatedAt = redraw, stored = "swap-a" to settledAt) + + assertEquals(settledAt, kept) + assertEquals(minutes(10), swapLiveTimeoutMs("failed", "refunded", finishedAtMs = kept, nowMs = redraw)) + } + + @Test + fun aSwapThatWasStillOpenStartsItsClockWhenItFinishes() { + val finish = settledAt + minutes(3) + assertEquals(finish, finishedAt("swap-a", "swapped", null, updatedAt = finish, stored = "swap-a" to null)) + } + + @Test + fun aDifferentSwapStartsItsOwnClock() { + val later = settledAt + minutes(40) + assertEquals(later, finishedAt("swap-b", "swapped", null, updatedAt = later, stored = "swap-a" to settledAt)) + } + + @Test + fun aSwapStillOpenHasNoFinishTime() { + for (phase in listOf("submitted", "confirming", "settling")) { + assertNull(phase, finishedAt("swap-a", phase, null, updatedAt = settledAt, stored = "swap-a" to settledAt)) + } + assertNull(finishedAt("swap-a", "failed", "incomplete", updatedAt = settledAt, stored = null)) + } + + private fun finishedAt( + key: String, + phase: String, + failure: String?, + updatedAt: Long?, + stored: Pair?, + ) = swapLiveFinishedAtMs(key, phase, failure, updatedAt, stored?.first, stored?.second) + + private fun minutes(count: Long) = count * 60_000L +} diff --git a/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveStaleTest.kt b/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveStaleTest.kt new file mode 100644 index 000000000..2ec2b3cf3 --- /dev/null +++ b/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveStaleTest.kt @@ -0,0 +1,27 @@ +package com.example.resonance_network_wallet + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/// A finished card has nothing left to check, so it never turns stale before it times out. +class SwapLiveStaleTest { + @Test + fun aFinishedSwapDoesNotScheduleTheStaleAlarm() { + assertFalse(swapLiveSchedulesStale("swapped", null)) + assertFalse(swapLiveSchedulesStale("failed", "failed")) + assertFalse(swapLiveSchedulesStale("failed", "refunded")) + } + + @Test + fun aSwapStillMovingSchedulesTheStaleAlarm() { + for (phase in listOf("submitted", "confirming", "settling")) { + assertTrue(phase, swapLiveSchedulesStale(phase, null)) + } + } + + @Test + fun anIncompleteDepositSchedulesTheStaleAlarm() { + assertTrue(swapLiveSchedulesStale("failed", "incomplete")) + } +} diff --git a/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveTimeoutTest.kt b/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveTimeoutTest.kt new file mode 100644 index 000000000..e17bc8f92 --- /dev/null +++ b/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveTimeoutTest.kt @@ -0,0 +1,48 @@ +package com.example.resonance_network_wallet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SwapLiveTimeoutTest { + @Test + fun aSwappedCardTimesOutFifteenMinutesAfterItFinished() { + assertEquals(minutes(15), swapLiveTimeoutMs("swapped", null, finishedAtMs = 0, nowMs = 0)) + } + + @Test + fun aFailedOrRefundedCardTimesOutThirtyMinutesAfterItFinished() { + assertEquals(minutes(30), swapLiveTimeoutMs("failed", "failed", finishedAtMs = 0, nowMs = 0)) + assertEquals(minutes(30), swapLiveTimeoutMs("failed", "refunded", finishedAtMs = 0, nowMs = 0)) + } + + @Test + fun aRepostCountsFromTheFinishNotFromThePost() { + assertEquals(minutes(13), swapLiveTimeoutMs("swapped", null, finishedAtMs = 0, nowMs = minutes(2))) + } + + @Test + fun aCardPastItsWindowTimesOutAtOnce() { + assertEquals(1L, swapLiveTimeoutMs("swapped", null, finishedAtMs = 0, nowMs = minutes(15))) + assertEquals(1L, swapLiveTimeoutMs("failed", "refunded", finishedAtMs = 0, nowMs = minutes(60))) + } + + @Test + fun anIncompleteDepositNeverTimesOut() { + assertNull(swapLiveTimeoutMs("failed", "incomplete", finishedAtMs = 0, nowMs = 0)) + } + + @Test + fun aSwapStillMovingNeverTimesOut() { + for (phase in listOf("submitted", "confirming", "settling")) { + assertNull(phase, swapLiveTimeoutMs(phase, null, finishedAtMs = 0, nowMs = 0)) + } + } + + @Test + fun aFinishedCardWithoutAFinishTimeHasNoTimeout() { + assertNull(swapLiveTimeoutMs("swapped", null, finishedAtMs = null, nowMs = 0)) + } + + private fun minutes(count: Long) = count * 60_000L +} diff --git a/mobile-app/ios/Runner.xcodeproj/project.pbxproj b/mobile-app/ios/Runner.xcodeproj/project.pbxproj index 937ed5821..b18abebe2 100644 --- a/mobile-app/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile-app/ios/Runner.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ A1B1C0062F00000100AA0006 /* SwapActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C0052F00000100AA0005 /* SwapActivityAttributes.swift */; }; A1B1C0072F00000100AA0007 /* SwapActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C0052F00000100AA0005 /* SwapActivityAttributes.swift */; }; A1B1C0092F00000100AA0009 /* SwapLiveActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C0082F00000100AA0008 /* SwapLiveActivityBridge.swift */; }; + A1B1C01D2F00000100AA001D /* SwapLiveCardRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B1C01C2F00000100AA001C /* SwapLiveCardRules.swift */; }; A1B1C00A2F00000100AA000A /* SwapLiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A1B1C0022F00000100AA0002 /* SwapLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; A1B1C00D2F00000100AA000D /* Geist-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = A1B1C00B2F00000100AA000B /* Geist-Regular.ttf */; }; A1B1C00E2F00000100AA000E /* Geist-Medium.ttf in Resources */ = {isa = PBXBuildFile; fileRef = A1B1C00C2F00000100AA000C /* Geist-Medium.ttf */; }; @@ -99,6 +100,7 @@ A1B1C01A2F00000100AA001A /* SwapCountdownClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapCountdownClock.swift; sourceTree = ""; }; A1B1C0052F00000100AA0005 /* SwapActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapActivityAttributes.swift; sourceTree = ""; }; A1B1C0082F00000100AA0008 /* SwapLiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapLiveActivityBridge.swift; sourceTree = ""; }; + A1B1C01C2F00000100AA001C /* SwapLiveCardRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapLiveCardRules.swift; sourceTree = ""; }; A1B1C00B2F00000100AA000B /* Geist-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Geist-Regular.ttf"; path = "../assets/fonts/Geist-Regular.ttf"; sourceTree = ""; }; A1B1C00C2F00000100AA000C /* Geist-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Geist-Medium.ttf"; path = "../assets/fonts/Geist-Medium.ttf"; sourceTree = ""; }; A1B1C0132F00000100AA0013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -252,6 +254,7 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, A1B1C0082F00000100AA0008 /* SwapLiveActivityBridge.swift */, + A1B1C01C2F00000100AA001C /* SwapLiveCardRules.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -673,6 +676,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, A1B1C0092F00000100AA0009 /* SwapLiveActivityBridge.swift in Sources */, + A1B1C01D2F00000100AA001D /* SwapLiveCardRules.swift in Sources */, A1B1C0072F00000100AA0007 /* SwapActivityAttributes.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); diff --git a/mobile-app/ios/Runner/SwapLiveActivityBridge.swift b/mobile-app/ios/Runner/SwapLiveActivityBridge.swift index ef1c959bb..17cadc7d9 100644 --- a/mobile-app/ios/Runner/SwapLiveActivityBridge.swift +++ b/mobile-app/ios/Runner/SwapLiveActivityBridge.swift @@ -113,16 +113,30 @@ final class SwapLiveActivityBridge: NSObject, FlutterStreamHandler { @MainActor private func show(_ args: [String: Any]) async throws { let state = try contentState(args) - let content = ActivityContent(state: state, staleDate: state.staleAtDate) - if let activity = Activity.activities.first { + let content = ActivityContent(state: state, staleDate: swapLiveStaleDate(revision(state), staleAt: state.staleAt)) + let activities = Activity.activities + // An ended card stays on screen until its dismissal date, and no longer + // takes updates. Only a card that is still running is written to. + if let activity = activities.first(where: { $0.activityState != .ended && $0.activityState != .dismissed }) { // A completion push updates this activity directly. A later poll must // not move that same swap back to in progress. - if swapLiveLocalWriteAllowed(current: activity.content.state, incoming: state) { + switch swapLiveActiveCardAction(current: revision(activity.content.state), incoming: revision(state)) { + case .update: await activity.update(content) + case .end(let dismissAt): + await activity.end(content, dismissalPolicy: .after(dismissAt)) + case .ignore: + break } watch(activity) return } + guard swapLiveOpensCard(revision(state), ended: activities.map { revision($0.content.state) }) else { return } + // A finished card still waiting for its dismissal date makes way for the new swap. + for ended in activities { + suppressDismiss.insert(ended.id) + await ended.end(nil, dismissalPolicy: .immediate) + } let activity = try Activity.request(attributes: SwapActivityAttributes(), content: content, pushType: .token) watch(activity) } @@ -242,25 +256,9 @@ private func doubleValue(_ value: Any?) -> Double? { } } -/// Same rule as Android `swapLiveLocalWriteAllowed`. A different swap may -/// replace the activity. The swap already on screen does not move backward. -@available(iOS 16.2, *) -private func swapLiveLocalWriteAllowed( - current: SwapActivityAttributes.ContentState, - incoming: SwapActivityAttributes.ContentState -) -> Bool { - if current.key.isEmpty { return true } - if !incoming.key.isEmpty && incoming.key != current.key { return true } - if incoming.updatedAt < current.updatedAt { return false } - let finished = swapLiveTerminal(phase: current.phase, failure: current.failure) - if !finished { return true } - return swapLiveTerminal(phase: incoming.phase, failure: incoming.failure) -} - @available(iOS 16.2, *) -private func swapLiveTerminal(phase: String, failure: String?) -> Bool { - if phase == "swapped" { return true } - return phase == "failed" && failure != "incomplete" +private func revision(_ state: SwapActivityAttributes.ContentState) -> SwapLiveCardRevision { + SwapLiveCardRevision(key: state.key, updatedAt: state.updatedAt, phase: state.phase, failure: state.failure) } private enum SwapLiveActivityError: LocalizedError { diff --git a/mobile-app/ios/Runner/SwapLiveCardRules.swift b/mobile-app/ios/Runner/SwapLiveCardRules.swift new file mode 100644 index 000000000..a9fbbdfe8 --- /dev/null +++ b/mobile-app/ios/Runner/SwapLiveCardRules.swift @@ -0,0 +1,72 @@ +import Foundation + +/// How long a finished swap card stays after the swap finished. Same windows +/// as Android `swapLiveTimeoutMs` and the end push Senoti sends, so the card +/// leaves at the same time whichever side ends it. +let swapLiveSwappedWindow: TimeInterval = 15 * 60 +let swapLiveFailedWindow: TimeInterval = 30 * 60 + +/// The fields that order writes to the swap card. Epochs are milliseconds. +struct SwapLiveCardRevision { + let key: String + let updatedAt: Double + let phase: String + let failure: String? +} + +enum SwapLiveActiveCardAction: Equatable { + case update + case end(dismissAt: Date) + case ignore +} + +/// What a write does to the card still on screen. A finished swap ends the +/// card, which stays until its dismissal date. +func swapLiveActiveCardAction(current: SwapLiveCardRevision, incoming: SwapLiveCardRevision) -> SwapLiveActiveCardAction { + guard swapLiveLocalWriteAllowed(current: current, incoming: incoming) else { return .ignore } + if let dismissAt = swapLiveDismissalDate(phase: incoming.phase, failure: incoming.failure, updatedAt: incoming.updatedAt) { + return .end(dismissAt: dismissAt) + } + return .update +} + +/// Whether a write opens a new card when none is running. A finished swap never +/// opens one: with none running it either already left, or the user removed it. +/// [ended] are cards that ended but are still on screen until their dismissal +/// date, such as one a completion push ended. Their swap keeps the write order, +/// so a poll that missed the push cannot reopen that swap as in progress. +func swapLiveOpensCard(_ incoming: SwapLiveCardRevision, ended: [SwapLiveCardRevision]) -> Bool { + if swapLiveTerminal(phase: incoming.phase, failure: incoming.failure) { return false } + return ended.allSatisfy { swapLiveLocalWriteAllowed(current: $0, incoming: incoming) } +} + +/// When a finished card leaves, counted from the moment the swap finished. +/// Nil while the swap is still open, which includes an incomplete deposit. +func swapLiveDismissalDate(phase: String, failure: String?, updatedAt: Double) -> Date? { + guard swapLiveTerminal(phase: phase, failure: failure) else { return nil } + let window = phase == "swapped" ? swapLiveSwappedWindow : swapLiveFailedWindow + return Date(timeIntervalSince1970: updatedAt / 1000 + window) +} + +/// When the card switches to its stale layout. A finished card has nothing +/// left to check, so it goes stale only when it leaves, as Senoti's end push does. +func swapLiveStaleDate(_ incoming: SwapLiveCardRevision, staleAt: Double) -> Date { + swapLiveDismissalDate(phase: incoming.phase, failure: incoming.failure, updatedAt: incoming.updatedAt) + ?? Date(timeIntervalSince1970: staleAt / 1000) +} + +/// Same rule as Android `swapLiveLocalWriteAllowed`. A different swap may +/// replace the activity. The swap already on screen does not move backward. +func swapLiveLocalWriteAllowed(current: SwapLiveCardRevision, incoming: SwapLiveCardRevision) -> Bool { + if current.key.isEmpty { return true } + if !incoming.key.isEmpty && incoming.key != current.key { return true } + if incoming.updatedAt < current.updatedAt { return false } + let finished = swapLiveTerminal(phase: current.phase, failure: current.failure) + if !finished { return true } + return swapLiveTerminal(phase: incoming.phase, failure: incoming.failure) +} + +func swapLiveTerminal(phase: String, failure: String?) -> Bool { + if phase == "swapped" { return true } + return phase == "failed" && failure != "incomplete" +} diff --git a/mobile-app/ios/SwapLiveActivityTests/SwapLiveCardRulesTests.swift b/mobile-app/ios/SwapLiveActivityTests/SwapLiveCardRulesTests.swift new file mode 100644 index 000000000..5d56bd215 --- /dev/null +++ b/mobile-app/ios/SwapLiveActivityTests/SwapLiveCardRulesTests.swift @@ -0,0 +1,155 @@ +import Foundation + +/// When the app writes to the swap card, and when a finished card leaves. +/// +/// From `mobile-app/ios`: +/// swiftc -o /tmp/swap-live-card-rules \ +/// Runner/SwapLiveCardRules.swift \ +/// SwapLiveActivityTests/SwapLiveCardRulesTests.swift \ +/// && /tmp/swap-live-card-rules + +private var failures: [String] = [] + +private func expect(_ condition: Bool, _ message: String) { + if !condition { + failures.append(message) + } +} + +private let finishedAt: Double = 1_700_000_000_000 + +private func card( + _ phase: String, + failure: String? = nil, + key: String = "swap-a", + updatedAt: Double = finishedAt +) -> SwapLiveCardRevision { + SwapLiveCardRevision(key: key, updatedAt: updatedAt, phase: phase, failure: failure) +} + +private func date(minutesAfterFinish minutes: Double) -> Date { + Date(timeIntervalSince1970: finishedAt / 1000 + minutes * 60) +} + +@main +private struct SwapLiveCardRulesTest { + static func main() { + dismissalDates() + staleDates() + activeCardActions() + openingCards() + + if failures.isEmpty { + print("ok") + } else { + for failure in failures { + fputs("FAIL \(failure)\n", stderr) + } + exit(1) + } + } + + private static func dismissalDates() { + let swapped = swapLiveDismissalDate(phase: "swapped", failure: nil, updatedAt: finishedAt) + expect(swapped == date(minutesAfterFinish: 15), "swapped dismisses at \(String(describing: swapped))") + + for failure in ["failed", "refunded"] { + let failed = swapLiveDismissalDate(phase: "failed", failure: failure, updatedAt: finishedAt) + expect(failed == date(minutesAfterFinish: 30), "\(failure) dismisses at \(String(describing: failed))") + } + + let incomplete = swapLiveDismissalDate(phase: "failed", failure: "incomplete", updatedAt: finishedAt) + expect(incomplete == nil, "an incomplete deposit dismisses at \(String(describing: incomplete))") + + for phase in ["submitted", "confirming", "settling"] { + let moving = swapLiveDismissalDate(phase: phase, failure: nil, updatedAt: finishedAt) + expect(moving == nil, "\(phase) dismisses at \(String(describing: moving))") + } + } + + private static func staleDates() { + // A finished card has nothing left to check, so it never turns stale before it leaves. + let staleAt = finishedAt + 120_000 + let wantedStale = Date(timeIntervalSince1970: staleAt / 1000) + + let swapped = swapLiveStaleDate(card("swapped"), staleAt: staleAt) + expect(swapped == date(minutesAfterFinish: 15), "swapped goes stale at \(swapped)") + + for failure in ["failed", "refunded"] { + let failed = swapLiveStaleDate(card("failed", failure: failure), staleAt: staleAt) + expect(failed == date(minutesAfterFinish: 30), "\(failure) goes stale at \(failed)") + } + + let incomplete = swapLiveStaleDate(card("failed", failure: "incomplete"), staleAt: staleAt) + expect(incomplete == wantedStale, "an incomplete deposit goes stale at \(incomplete)") + + for phase in ["submitted", "confirming", "settling"] { + let moving = swapLiveStaleDate(card(phase), staleAt: staleAt) + expect(moving == wantedStale, "\(phase) goes stale at \(moving)") + } + } + + private static func activeCardActions() { + let finish = swapLiveActiveCardAction(current: card("settling"), incoming: card("swapped")) + expect(finish == .end(dismissAt: date(minutesAfterFinish: 15)), "a finished swap on its card gave \(finish)") + + let refund = swapLiveActiveCardAction(current: card("settling"), incoming: card("failed", failure: "refunded")) + expect(refund == .end(dismissAt: date(minutesAfterFinish: 30)), "a refund on its card gave \(refund)") + + let moving = swapLiveActiveCardAction( + current: card("confirming", updatedAt: finishedAt - 60_000), + incoming: card("settling") + ) + expect(moving == .update, "a swap still moving gave \(moving)") + + let incomplete = swapLiveActiveCardAction(current: card("settling"), incoming: card("failed", failure: "incomplete")) + expect(incomplete == .update, "an incomplete deposit gave \(incomplete)") + + let older = swapLiveActiveCardAction( + current: card("swapped"), + incoming: card("settling", updatedAt: finishedAt + 60_000) + ) + expect(older == .ignore, "an in-progress write after the finish gave \(older)") + + let stale = swapLiveActiveCardAction( + current: card("settling"), + incoming: card("settling", updatedAt: finishedAt - 60_000) + ) + expect(stale == .ignore, "an older revision gave \(stale)") + + let otherSwap = swapLiveActiveCardAction( + current: card("swapped"), + incoming: card("submitted", key: "swap-b", updatedAt: finishedAt - 60_000) + ) + expect(otherSwap == .update, "a different swap on the card gave \(otherSwap)") + } + + private static func openingCards() { + expect(!swapLiveOpensCard(card("swapped"), ended: []), "a finished swap opened a card") + expect(!swapLiveOpensCard(card("failed", failure: "failed"), ended: []), "a failed swap opened a card") + expect(!swapLiveOpensCard(card("failed", failure: "refunded"), ended: []), "a refunded swap opened a card") + expect(swapLiveOpensCard(card("submitted", key: "swap-b"), ended: []), "a new swap did not open a card") + expect(swapLiveOpensCard(card("failed", failure: "incomplete"), ended: []), "an incomplete deposit did not open a card") + + // A completion push ended swap-a's card, which waits on screen for its + // dismissal date. The app missed the push and still holds settling, and its + // refresh carries a newer updatedAt than the push. + let completed = [card("swapped")] + let delayedPoll = card("settling", updatedAt: finishedAt + 45_000) + expect(!swapLiveOpensCard(delayedPoll, ended: completed), "a delayed poll reopened a completed swap") + + let refunded = [card("failed", failure: "refunded")] + expect(!swapLiveOpensCard(delayedPoll, ended: refunded), "a delayed poll reopened a refunded swap") + + let nextSwap = card("submitted", key: "swap-b", updatedAt: finishedAt + 45_000) + expect(swapLiveOpensCard(nextSwap, ended: completed), "a new swap did not open past an ended card") + + // A card the app ended while the swap was still open does not block that swap. + let endedEarly = [card("submitted", updatedAt: finishedAt - 60_000)] + expect(swapLiveOpensCard(card("confirming"), ended: endedEarly), "an open swap stayed closed after an early end") + expect( + !swapLiveOpensCard(card("submitted", updatedAt: finishedAt - 120_000), ended: endedEarly), + "an older revision reopened an ended card" + ) + } +} diff --git a/mobile-app/lib/generated/version.g.dart b/mobile-app/lib/generated/version.g.dart index b7af5a62c..fe18c0be3 100644 --- a/mobile-app/lib/generated/version.g.dart +++ b/mobile-app/lib/generated/version.g.dart @@ -1,2 +1,2 @@ const appVersion = '1.7.8'; -const appBuildNumber = '176'; +const appBuildNumber = '178'; diff --git a/mobile-app/lib/providers/swap_live_activity_provider.dart b/mobile-app/lib/providers/swap_live_activity_provider.dart index 933271b59..80e34b2f1 100644 --- a/mobile-app/lib/providers/swap_live_activity_provider.dart +++ b/mobile-app/lib/providers/swap_live_activity_provider.dart @@ -274,6 +274,7 @@ class SwapLiveActivityController { } Future _registerToken(String token, String platform, SwapLiveActivityContent content, int epoch) async { + if (content.simulated) return; if (!_tracks(epoch, content.key)) return; _attempted.add(token); try { diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index aeef9a492..6c47105c6 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -2,7 +2,7 @@ name: resonance_network_wallet description: A Flutter wallet for the Quantus blockchain. publish_to: "none" -version: 1.7.8+176 +version: 1.7.8+178 environment: sdk: ">=3.8.0 <4.0.0" diff --git a/mobile-app/test/unit/swap_live_activity_test.dart b/mobile-app/test/unit/swap_live_activity_test.dart index ca9f2a62c..18c0cd64c 100644 --- a/mobile-app/test/unit/swap_live_activity_test.dart +++ b/mobile-app/test/unit/swap_live_activity_test.dart @@ -353,6 +353,15 @@ void main() { expect(registrar.calls, isEmpty); }); + test("a simulated swap's activity token is not registered", () async { + current = swap(SwapStatus.pendingDeposit, simulated: true); + await controller.sync(); + card.emit(SwapLiveToken('activity-token')); + await pumpEventQueue(); + + expect(registrar.calls, isEmpty); + }); + test('an activity token is registered for iOS push', () async { await controller.sync(); card.emit(SwapLiveToken('activity-token'));