Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()))
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -428,6 +454,7 @@ object SwapLiveNotifications {
val updatedAt: Long?,
val phase: String?,
val failure: String?,
val finishedAt: Long?,
) {
val revision = SwapLiveCardRevision(updatedAt, phase, failure)
}
Expand All @@ -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)),
)
}

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Long?>?,
) = swapLiveFinishedAtMs(key, phase, failure, updatedAt, stored?.first, stored?.second)

private fun minutes(count: Long) = count * 60_000L
}
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions mobile-app/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -99,6 +100,7 @@
A1B1C01A2F00000100AA001A /* SwapCountdownClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapCountdownClock.swift; sourceTree = "<group>"; };
A1B1C0052F00000100AA0005 /* SwapActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapActivityAttributes.swift; sourceTree = "<group>"; };
A1B1C0082F00000100AA0008 /* SwapLiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapLiveActivityBridge.swift; sourceTree = "<group>"; };
A1B1C01C2F00000100AA001C /* SwapLiveCardRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapLiveCardRules.swift; sourceTree = "<group>"; };
A1B1C00B2F00000100AA000B /* Geist-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Geist-Regular.ttf"; path = "../assets/fonts/Geist-Regular.ttf"; sourceTree = "<group>"; };
A1B1C00C2F00000100AA000C /* Geist-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Geist-Medium.ttf"; path = "../assets/fonts/Geist-Medium.ttf"; sourceTree = "<group>"; };
A1B1C0132F00000100AA0013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -252,6 +254,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
A1B1C0082F00000100AA0008 /* SwapLiveActivityBridge.swift */,
A1B1C01C2F00000100AA001C /* SwapLiveCardRules.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
Expand Down Expand Up @@ -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 */,
);
Expand Down
Loading
Loading