diff --git a/mobile-app/android/app/src/main/AndroidManifest.xml b/mobile-app/android/app/src/main/AndroidManifest.xml index 47507da78..fae7eb667 100644 --- a/mobile-app/android/app/src/main/AndroidManifest.xml +++ b/mobile-app/android/app/src/main/AndroidManifest.xml @@ -8,8 +8,6 @@ - - @@ -63,10 +61,6 @@ - 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 8c22e1b4c..611a74f47 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 @@ -6,18 +6,14 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.content.pm.ServiceInfo import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.os.SystemClock -import android.util.Log +import android.text.format.DateFormat import android.view.View import android.widget.RemoteViews import io.flutter.plugin.common.BinaryMessenger @@ -25,12 +21,15 @@ import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import org.json.JSONObject +import java.util.Date /// The swap card on the lock screen, and the status-bar progress on Android 16. +/// A plain notification: nothing keeps the process alive for it. The running +/// app and pushes redraw it, and an alarm marks it stale once neither has for +/// a while. object SwapLiveNotifications { const val NOTIFICATION_ID = 71041 const val CHANNEL_ID = "swap_live_activity" - const val EXTRA_PAYLOAD = "payload" const val ACTION_STALE = "com.quantus.wallet.SWAP_LIVE_STALE" const val ACTION_DISMISSED = "com.quantus.wallet.SWAP_LIVE_DISMISSED" const val MESSAGE_TYPE = "swap_live_activity" @@ -38,7 +37,6 @@ object SwapLiveNotifications { private const val PREFS = "swap_live_activity" private const val PREF_PAYLOAD = "payload" private const val PREF_DISMISSED = "dismissed_key" - 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" @@ -165,9 +163,9 @@ object SwapLiveNotifications { fun show(context: Context, json: JSONObject, stale: Boolean) { synchronized(revisionLock) { // A poll can arrive after a completion push. The same order that - // guards replace applies here, or the poll starts the foreground - // service again for a swap that has already finished. A dismissal - // persisted while no listener was attached is refused here too. + // guards replace applies here, or the poll puts a finished swap back + // in progress. A dismissal persisted while no listener was attached + // is refused here too. if (!acceptsLocalWrite(context, json)) return ignoreDelete = false val card = if (stale) json.put("stale", true) else json @@ -178,13 +176,7 @@ object SwapLiveNotifications { val key = card.optString("key") if (!dismissed.isNullOrEmpty() && dismissed != key) editor.remove(PREF_DISMISSED) editor.apply() - val holds = card.optBoolean("holdsProcess") && !stale - if (stale) { - // The alarm can fire with the app suspended, which may not start a service. - context.getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, build(context, card)) - } else { - launch(context, card, holds) - } + context.getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, build(context, card)) if (!stale) scheduleStale(context, card) } } @@ -213,9 +205,7 @@ object SwapLiveNotifications { if (prefs.contains(PREF_PAYLOAD)) ignoreDelete = true cancelStale(context) prefs.edit().remove(PREF_PAYLOAD).apply() - val manager = context.getSystemService(NotificationManager::class.java) - manager.cancel(NOTIFICATION_ID) - context.stopService(Intent(context, SwapLiveActivityService::class.java)) + context.getSystemService(NotificationManager::class.java).cancel(NOTIFICATION_ID) } fun onDeleted(context: Context) { @@ -307,7 +297,7 @@ object SwapLiveNotifications { views.setTextViewText(R.id.swap_live_from_symbol, json.optString("fromSymbol")) views.setTextViewText(R.id.swap_live_to_amount, json.optString("toAmount")) views.setTextViewText(R.id.swap_live_to_symbol, json.optString("toSymbol")) - bindTiming(views, json, stale) + bindTiming(context, views, json, stale) val showProgress = json.optBoolean("showProgress") && !stale views.setViewVisibility(R.id.swap_live_progress_wrap, if (showProgress) View.VISIBLE else View.GONE) if (showProgress) { @@ -345,7 +335,7 @@ object SwapLiveNotifications { return builder.build() } - private fun bindTiming(views: RemoteViews, json: JSONObject, stale: Boolean) { + private fun bindTiming(context: Context, views: RemoteViews, json: JSONObject, stale: Boolean) { val now = System.currentTimeMillis() val elapsed = SystemClock.elapsedRealtime() if (stale) { @@ -361,31 +351,12 @@ object SwapLiveNotifications { views.setViewVisibility(R.id.swap_live_eta, View.GONE) return } - val endsAt = json.optLong("endsAt") + // The time the swap should be done by, such as "~2:57 PM". Nothing + // redraws the card between updates, so a count of the minutes left + // would go wrong. A time of day does not. + val eta = DateFormat.getTimeFormat(context).format(Date(json.optLong("endsAt"))) views.setViewVisibility(R.id.swap_live_eta, View.VISIBLE) - views.setTextViewText( - R.id.swap_live_eta, - swapLiveTimeLeft( - nowMs = now, - endsAtMs = endsAt, - minute = json.optString("minuteLabel"), - hour = json.optString("hourLabel"), - left = json.optString("leftLabel"), - ), - ) - } - - /// The card to redraw while its countdown is still running. A dismissed or - /// stale card stays as the user left it. - internal fun tickingCard(context: Context): JSONObject? { - val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) - val raw = prefs.getString(PREF_PAYLOAD, null) ?: return null - val json = JSONObject(raw) - val dismissed = prefs.getString(PREF_DISMISSED, null) - val key = json.optString("key") - if (!dismissed.isNullOrEmpty() && dismissed == key) return null - if (json.optBoolean("stale") || json.isNull("endsAt")) return null - return json + views.setTextViewText(R.id.swap_live_eta, "~$eta") } private fun ensureChannel(context: Context, name: String) { @@ -407,21 +378,6 @@ object SwapLiveNotifications { alarm.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, staleAt, broadcast(context, ACTION_STALE, 1)) } - private fun launch(context: Context, card: JSONObject, holds: Boolean) { - val launch = Intent(context, SwapLiveActivityService::class.java).putExtra(EXTRA_PAYLOAD, card.toString()) - try { - if (holds && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(launch) - } else { - context.startService(launch) - } - } catch (error: IllegalStateException) { - // A push can arrive while the process is backgrounded and not allowed to start a service. - Log.w(TAG, "Updating the swap card without a service", error) - context.getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, build(context, card)) - } - } - private class StoredSwapCard( val key: String?, val dismissedKey: String?, @@ -478,92 +434,6 @@ object SwapLiveNotifications { } } -/// "~5 min left", rounding the remainder up to the next minute. Keep in step -/// with `swapTimeLeft` in the iOS Live Activity. A blank unit is the word used -/// by a card started before those labels were sent. -internal fun swapLiveTimeLeft(nowMs: Long, endsAtMs: Long, minute: String, hour: String, left: String): String { - val minuteWord = minute.ifBlank { "min" } - val hourWord = hour.ifBlank { "hr" } - val leftWord = left.ifBlank { "left" } - val seconds = (endsAtMs - nowMs) / 1000.0 - val minutes = if (seconds <= 0.0) 0 else kotlin.math.ceil(seconds / 60.0).toInt() - if (minutes < 60) return "~$minutes $minuteWord $leftWord" - val hours = minutes / 60 - val rest = minutes % 60 - if (rest == 0) return "~$hours $hourWord $leftWord" - return "~$hours $hourWord $rest $minuteWord $leftWord" -} - -/// Holds the process while a swap is still moving, so status polls keep running -/// with the screen off. A finished card is a normal notification and the service stops. -class SwapLiveActivityService : Service() { - private val handler = Handler(Looper.getMainLooper()) - private val tick = Runnable { onTick() } - - override fun onBind(intent: Intent?) = null - - override fun onDestroy() { - handler.removeCallbacks(tick) - super.onDestroy() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val raw = intent?.getStringExtra(SwapLiveNotifications.EXTRA_PAYLOAD) - if (raw == null) { - stopSelf() - return START_NOT_STICKY - } - val json = JSONObject(raw) - val notification = SwapLiveNotifications.build(this, json) - val holds = json.optBoolean("holdsProcess") && !json.optBoolean("stale") - if (holds && !json.isNull("endsAt")) { - handler.removeCallbacks(tick) - handler.postDelayed(tick, TICK_MS) - } else { - handler.removeCallbacks(tick) - } - if (holds) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - SwapLiveNotifications.NOTIFICATION_ID, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, - ) - } else { - startForeground(SwapLiveNotifications.NOTIFICATION_ID, notification) - } - } else { - getSystemService(NotificationManager::class.java).notify(SwapLiveNotifications.NOTIFICATION_ID, notification) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - try { - stopForeground(STOP_FOREGROUND_DETACH) - } catch (_: IllegalArgumentException) { - // The card was never a foreground notification. - } - } - stopSelf() - } - return START_NOT_STICKY - } - - private fun onTick() { - val json = SwapLiveNotifications.tickingCard(this) - if (json == null) { - handler.removeCallbacks(tick) - return - } - val staleAt = if (json.isNull("staleAt")) 0L else json.optLong("staleAt") - if (staleAt > 0L && System.currentTimeMillis() >= staleAt) return - val manager = getSystemService(NotificationManager::class.java) - manager.notify(SwapLiveNotifications.NOTIFICATION_ID, SwapLiveNotifications.build(this, json)) - handler.postDelayed(tick, TICK_MS) - } - - private companion object { - const val TICK_MS = 20_000L - } -} - class SwapLiveActivityReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { diff --git a/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveTimeLeftTest.kt b/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveTimeLeftTest.kt deleted file mode 100644 index bc8aa3bc7..000000000 --- a/mobile-app/android/app/src/test/kotlin/com/example/resonance_network_wallet/SwapLiveTimeLeftTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.example.resonance_network_wallet - -import org.junit.Assert.assertEquals -import org.junit.Test - -class SwapLiveTimeLeftTest { - private val minute = "min" - private val hour = "hr" - private val left = "left" - - @Test - fun anExactFiveMinutesReadsAsFiveMinLeft() { - assertEquals("~5 min left", left(now = 0, endsAt = minutes(5))) - } - - @Test - fun aPartialMinuteRoundsUpUntilTheNextWholeMinute() { - assertEquals("~5 min left", left(now = 0, endsAt = minutes(4) + 1_000)) - assertEquals("~4 min left", left(now = 0, endsAt = minutes(4))) - } - - @Test - fun noTimeLeftIsZeroMinutes() { - assertEquals("~0 min left", left(now = minutes(5), endsAt = minutes(5))) - assertEquals("~0 min left", left(now = minutes(6), endsAt = minutes(5))) - } - - @Test - fun anHourDropsTheMinuteUnitAndARemainderKeepsBoth() { - assertEquals("~1 hr left", left(now = 0, endsAt = minutes(60))) - assertEquals("~1 hr 30 min left", left(now = 0, endsAt = minutes(90))) - } - - @Test - fun aBlankUnitUsesTheWordFromCardsThatPredateTheLabel() { - assertEquals("~5 min left", swapLiveTimeLeft(0, minutes(5), minute = "", hour = "", left = "")) - } - - private fun left(now: Long, endsAt: Long) = swapLiveTimeLeft(now, endsAt, minute, hour, left) - - private fun minutes(count: Int) = count * 60_000L -} diff --git a/mobile-app/ios/SwapLiveActivity/SwapCountdownClock.swift b/mobile-app/ios/SwapLiveActivity/SwapCountdownClock.swift index 4dfd8d54e..aaae46084 100644 --- a/mobile-app/ios/SwapLiveActivity/SwapCountdownClock.swift +++ b/mobile-app/ios/SwapLiveActivity/SwapCountdownClock.swift @@ -1,7 +1,6 @@ import SwiftUI -/// "~5 min left", rounding the remainder up to the next minute. Keep in step -/// with `swapLiveTimeLeft` on Android. +/// "~5 min left", rounding the remainder up to the next minute. func swapTimeLeft(endsAt: Date, now: Date, minute: String, hour: String, left: String) -> String { let minutes = swapMinutesRemaining(until: endsAt, now: now) if minutes < 60 { diff --git a/mobile-app/lib/services/swap_live_activity.dart b/mobile-app/lib/services/swap_live_activity.dart index 61eb56e60..82ff08671 100644 --- a/mobile-app/lib/services/swap_live_activity.dart +++ b/mobile-app/lib/services/swap_live_activity.dart @@ -118,6 +118,10 @@ class SwapLiveActivityContent { final DateTime updatedAt; final String depositAddress; final String key; + + /// Whether the swap is still moving, which keeps the Android card ongoing. + /// The name is the wire key Senoti and both native cards read. It dates + /// from an Android service that held the process. final bool holdsProcess; final bool simulated; final String locale; @@ -204,7 +208,7 @@ class SwapLiveActivityContent { } /// Whether [swap] ended without paying out. Incomplete deposits keep being -/// polled, so they still hold the Android process. +/// polled, so their card stays ongoing. SwapLiveFailure? swapLiveFailure(ActiveSwap swap) => switch (swap.order.status) { SwapStatus.failed => SwapLiveFailure.failed, SwapStatus.refunded => SwapLiveFailure.refunded,