From dc0b348968f39af565fb114e0a2c7fcb4a597932 Mon Sep 17 00:00:00 2001
From: Dominique Padiou <5765435+dpad85@users.noreply.github.com>
Date: Thu, 29 Feb 2024 16:02:42 +0100
Subject: [PATCH] Settle in-flight payments in the background (#522)
In-flight payments may time out and trigger channels' closures
if the app does not connect to the peer in time. Usually this is
taken care of by FCM silent push notifications sent by the peer.
However these notifications may fail.
To fix this edge case, Phoenix now schedules background tasks
connecting to the peer whenever there are pending payments.
On iOS, this job may run for 30 seconds, on Android 2 minutes.
On Android, we also display a badge in the Home screen that
counts pending htlcs.
---------
Co-authored-by: Robbie Hanson <304604+robbiehanson@users.noreply.github.com>
Co-authored-by: dluvian <133484344+dluvian@users.noreply.github.com>
---
phoenix-android/src/main/AndroidManifest.xml | 2 +
.../acinq/phoenix/android/home/HomeBalance.kt | 1 +
.../phoenix/android/home/HomeTopAndBottom.kt | 166 +++++---
.../fr/acinq/phoenix/android/home/HomeView.kt | 12 +-
.../phoenix/android/services/BootReceiver.kt | 1 +
.../android/services/ChannelsWatcher.kt | 14 +-
.../services/InflightPaymentsWatcher.kt | 275 ++++++++++++
.../phoenix/android/services/NodeService.kt | 44 +-
.../android/utils/SystemNotificationHelper.kt | 15 +
.../utils/datastore/InternalDataRepository.kt | 4 +
.../res/values-b+es+419/important_strings.xml | 3 +
.../main/res/values-cs/important_strings.xml | 3 +
.../src/main/res/values-cs/strings.xml | 2 +-
.../main/res/values-de/important_strings.xml | 3 +
.../src/main/res/values-de/strings.xml | 2 +-
.../main/res/values-es/important_strings.xml | 3 +
.../main/res/values-fr/important_strings.xml | 3 +
.../src/main/res/values-fr/strings.xml | 4 +-
.../res/values-pt-rBR/important_strings.xml | 3 +
.../src/main/res/values/important_strings.xml | 3 +
.../src/main/res/values/strings.xml | 5 +-
phoenix-ios/phoenix-ios/AppDelegate.swift | 41 --
.../kotlin/KotlinExtensions+Other.swift | 41 +-
.../officers/BusinessManager.swift | 56 ++-
.../phoenix-ios/officers/WatchTower.swift | 391 +++++++++++++-----
.../NotificationService.swift | 47 ++-
phoenix-legacy/src/main/AndroidManifest.xml | 1 +
.../legacy/background/EclairNodeService.kt | 14 +-
.../fr.acinq.phoenix/PhoenixBusiness.kt | 3 +
.../fr.acinq.phoenix/data/LocalChannelInfo.kt | 17 +
.../managers/AppConnectionsDaemon.kt | 2 +-
31 files changed, 960 insertions(+), 221 deletions(-)
create mode 100644 phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt
diff --git a/phoenix-android/src/main/AndroidManifest.xml b/phoenix-android/src/main/AndroidManifest.xml
index ed3155b1a..e6518cdaa 100644
--- a/phoenix-android/src/main/AndroidManifest.xml
+++ b/phoenix-android/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
+
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeBalance.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeBalance.kt
index 308d07840..a55cb59a7 100644
--- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeBalance.kt
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeBalance.kt
@@ -19,6 +19,7 @@ package fr.acinq.phoenix.android.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt
index dff99e0e3..e11daa70e 100644
--- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeTopAndBottom.kt
@@ -25,8 +25,10 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -38,17 +40,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.acinq.lightning.utils.Connection
import fr.acinq.phoenix.android.R
-import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.BorderButton
import fr.acinq.phoenix.android.components.Button
+import fr.acinq.phoenix.android.components.Dialog
import fr.acinq.phoenix.android.components.FilledButton
import fr.acinq.phoenix.android.components.VSeparator
import fr.acinq.phoenix.android.components.openLink
-import fr.acinq.phoenix.android.utils.borderColor
import fr.acinq.phoenix.android.utils.isBadCertificate
import fr.acinq.phoenix.android.utils.mutedBgColor
import fr.acinq.phoenix.android.utils.negativeColor
-import fr.acinq.phoenix.android.utils.orange
import fr.acinq.phoenix.android.utils.positiveColor
import fr.acinq.phoenix.android.utils.warningColor
import fr.acinq.phoenix.data.canRequestLiquidity
@@ -62,20 +62,12 @@ fun TopBar(
electrumBlockheight: Int,
onTorClick: () -> Unit,
isTorEnabled: Boolean?,
+ inFlightPaymentsCount: Int,
+ showRequestLiquidity: Boolean,
onRequestLiquidityClick: () -> Unit,
) {
- val channelsState by business.peerManager.channelsFlow.collectAsState()
val context = LocalContext.current
- val connectionsTransition = rememberInfiniteTransition(label = "animateConnectionsBadge")
- val connectionsButtonAlpha by connectionsTransition.animateFloat(
- label = "animateConnectionsBadge",
- initialValue = 0.3f,
- targetValue = 1f,
- animationSpec = infiniteRepeatable(
- animation = keyframes { durationMillis = 500 },
- repeatMode = RepeatMode.Reverse
- ),
- )
+
Row(
modifier = modifier
.fillMaxWidth()
@@ -83,49 +75,21 @@ fun TopBar(
.height(40.dp)
.clipToBounds()
) {
- if (connections.electrum !is Connection.ESTABLISHED || connections.peer !is Connection.ESTABLISHED) {
- val electrumConnection = connections.electrum
- val isBadElectrumCert = electrumConnection is Connection.CLOSED && electrumConnection.isBadCertificate()
- FilledButton(
- text = stringResource(id = if (isBadElectrumCert) R.string.home__connection__bad_cert else R.string.home__connection__connecting),
- icon = if (isBadElectrumCert) R.drawable.ic_alert_triangle else R.drawable.ic_connection_lost,
- iconTint = if (isBadElectrumCert) negativeColor else MaterialTheme.colors.onSurface,
- onClick = onConnectionsStateButtonClick,
- textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp, color = if (isBadElectrumCert) negativeColor else MaterialTheme.colors.onSurface),
- backgroundColor = MaterialTheme.colors.surface,
- space = 8.dp,
- padding = PaddingValues(8.dp),
- modifier = Modifier.alpha(connectionsButtonAlpha)
- )
- } else if (electrumBlockheight < 795_000) {
- // FIXME use a dynamic blockheight ^
- FilledButton(
- text = stringResource(id = R.string.home__connection__electrum_late),
- icon = R.drawable.ic_alert_triangle,
- iconTint = warningColor,
- onClick = onConnectionsStateButtonClick,
- textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp),
- backgroundColor = MaterialTheme.colors.surface,
- space = 8.dp,
- padding = PaddingValues(8.dp),
- modifier = Modifier.alpha(connectionsButtonAlpha)
- )
- } else if (isTorEnabled == true) {
- if (connections.tor is Connection.ESTABLISHED) {
- FilledButton(
- text = stringResource(id = R.string.home__connection__tor_active),
- icon = R.drawable.ic_tor_shield_ok,
- iconTint = positiveColor,
- onClick = onTorClick,
- textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp),
- backgroundColor = mutedBgColor,
- space = 8.dp,
- padding = PaddingValues(8.dp)
- )
- }
+ ConnectionBadge(
+ onConnectionsStateButtonClick = onConnectionsStateButtonClick,
+ connections = connections,
+ electrumBlockheight = electrumBlockheight,
+ onTorClick = onTorClick,
+ isTorEnabled = isTorEnabled,
+ )
+
+ if (inFlightPaymentsCount > 0) {
+ InflightPaymentsBadge(inFlightPaymentsCount)
}
+
Spacer(modifier = Modifier.weight(1f))
- if (channelsState.canRequestLiquidity()) {
+
+ if (showRequestLiquidity) {
BorderButton(
text = stringResource(id = R.string.home_request_liquidity),
icon = R.drawable.ic_bucket,
@@ -137,6 +101,7 @@ fun TopBar(
)
Spacer(modifier = Modifier.width(4.dp))
}
+
FilledButton(
text = stringResource(R.string.home__faq_button),
icon = R.drawable.ic_help_circle,
@@ -150,6 +115,95 @@ fun TopBar(
}
}
+@Composable
+private fun RowScope.ConnectionBadge(
+ onConnectionsStateButtonClick: () -> Unit,
+ connections: Connections,
+ electrumBlockheight: Int,
+ onTorClick: () -> Unit,
+ isTorEnabled: Boolean?,
+) {
+ val connectionsTransition = rememberInfiniteTransition(label = "animateConnectionsBadge")
+ val connectionsButtonAlpha by connectionsTransition.animateFloat(
+ label = "animateConnectionsBadge",
+ initialValue = 0.3f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = keyframes { durationMillis = 500 },
+ repeatMode = RepeatMode.Reverse
+ ),
+ )
+
+ if (connections.electrum !is Connection.ESTABLISHED || connections.peer !is Connection.ESTABLISHED) {
+ val electrumConnection = connections.electrum
+ val isBadElectrumCert = electrumConnection is Connection.CLOSED && electrumConnection.isBadCertificate()
+ FilledButton(
+ text = stringResource(id = if (isBadElectrumCert) R.string.home__connection__bad_cert else R.string.home__connection__connecting),
+ icon = if (isBadElectrumCert) R.drawable.ic_alert_triangle else R.drawable.ic_connection_lost,
+ iconTint = if (isBadElectrumCert) negativeColor else MaterialTheme.colors.onSurface,
+ onClick = onConnectionsStateButtonClick,
+ textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp, color = if (isBadElectrumCert) negativeColor else MaterialTheme.colors.onSurface),
+ backgroundColor = MaterialTheme.colors.surface,
+ space = 8.dp,
+ padding = PaddingValues(8.dp),
+ modifier = Modifier.alpha(connectionsButtonAlpha)
+ )
+ } else if (electrumBlockheight < 795_000) {
+ // FIXME use a dynamic blockheight ^
+ FilledButton(
+ text = stringResource(id = R.string.home__connection__electrum_late),
+ icon = R.drawable.ic_alert_triangle,
+ iconTint = warningColor,
+ onClick = onConnectionsStateButtonClick,
+ textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp),
+ backgroundColor = MaterialTheme.colors.surface,
+ space = 8.dp,
+ padding = PaddingValues(8.dp),
+ modifier = Modifier.alpha(connectionsButtonAlpha)
+ )
+ } else if (isTorEnabled == true) {
+ if (connections.tor is Connection.ESTABLISHED) {
+ FilledButton(
+ text = stringResource(id = R.string.home__connection__tor_active),
+ icon = R.drawable.ic_tor_shield_ok,
+ iconTint = positiveColor,
+ onClick = onTorClick,
+ textStyle = MaterialTheme.typography.button.copy(fontSize = 12.sp),
+ backgroundColor = mutedBgColor,
+ space = 8.dp,
+ padding = PaddingValues(8.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun RowScope.InflightPaymentsBadge(
+ count: Int,
+) {
+ var showInflightPaymentsDialog by remember { mutableStateOf(false) }
+
+ FilledButton(
+ text = "$count",
+ icon = R.drawable.ic_send,
+ iconTint = MaterialTheme.colors.onPrimary,
+ onClick = { showInflightPaymentsDialog = true },
+ textStyle = MaterialTheme.typography.body2.copy(fontSize = 12.sp, color = MaterialTheme.colors.onPrimary),
+ backgroundColor = MaterialTheme.colors.primary,
+ space = 8.dp,
+ padding = PaddingValues(8.dp),
+ )
+
+ if (showInflightPaymentsDialog) {
+ Dialog(onDismiss = { showInflightPaymentsDialog = false }) {
+ Text(
+ text = stringResource(id = R.string.home_inflight_payments, count),
+ modifier = Modifier.padding(24.dp)
+ )
+ }
+ }
+}
+
@Composable
fun BottomBar(
modifier: Modifier = Modifier,
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt
index 9687d79cb..f66e216d1 100644
--- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/home/HomeView.kt
@@ -58,6 +58,8 @@ import fr.acinq.phoenix.android.utils.datastore.HomeAmountDisplayMode
import fr.acinq.phoenix.android.utils.datastore.UserPrefs
import fr.acinq.phoenix.android.utils.findActivity
import fr.acinq.phoenix.data.WalletPaymentId
+import fr.acinq.phoenix.data.canRequestLiquidity
+import fr.acinq.phoenix.data.inFlightPaymentsCount
import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@@ -79,11 +81,15 @@ fun HomeView(
onRequestLiquidityClick: () -> Unit,
) {
val context = LocalContext.current
+
+ val internalData = application.internalDataRepository
val torEnabledState = UserPrefs.getIsTorEnabled(context).collectAsState(initial = null)
+ val balanceDisplayMode by UserPrefs.getHomeAmountDisplayMode(context).collectAsState(initial = HomeAmountDisplayMode.REDACTED)
+
val connections by business.connectionsManager.connections.collectAsState()
val electrumMessages by business.appConfigurationManager.electrumMessages.collectAsState()
- val balanceDisplayMode by UserPrefs.getHomeAmountDisplayMode(context).collectAsState(initial = HomeAmountDisplayMode.REDACTED)
- val internalData = application.internalDataRepository
+ val channels by business.peerManager.channelsFlow.collectAsState()
+ val inFlightPaymentsCount = remember(channels) { channels.inFlightPaymentsCount() }
var showConnectionsDialog by remember { mutableStateOf(false) }
if (showConnectionsDialog) {
@@ -214,8 +220,10 @@ fun HomeView(
onConnectionsStateButtonClick = { showConnectionsDialog = true },
connections = connections,
electrumBlockheight = electrumMessages?.blockHeight ?: 0,
+ inFlightPaymentsCount = inFlightPaymentsCount,
isTorEnabled = torEnabledState.value,
onTorClick = onTorClick,
+ showRequestLiquidity = channels.canRequestLiquidity(),
onRequestLiquidityClick = onRequestLiquidityClick,
)
HomeBalance(
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt
index 801b6bb88..b4bd9ad87 100644
--- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/BootReceiver.kt
@@ -27,6 +27,7 @@ class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
ChannelsWatcher.schedule(context)
+ InflightPaymentsWatcher.scheduleOnce(context)
}
}
}
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt
index 985254a2b..6b902994d 100644
--- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/ChannelsWatcher.kt
@@ -150,24 +150,24 @@ class ChannelsWatcher(context: Context, workerParams: WorkerParameters) : Corout
companion object {
private val log = LoggerFactory.getLogger(ChannelsWatcher::class.java)
- private const val WATCHER_WORKER_TAG = BuildConfig.APPLICATION_ID + ".ChannelsWatcher"
+ const val TAG = BuildConfig.APPLICATION_ID + ".ChannelsWatcher"
private const val ELECTRUM_TIMEOUT_MILLIS = 5 * 60_000L
fun schedule(context: Context) {
log.info("scheduling channels watcher")
- val work = PeriodicWorkRequest.Builder(ChannelsWatcher::class.java, 36, TimeUnit.HOURS, 12, TimeUnit.HOURS)
- .addTag(WATCHER_WORKER_TAG)
- WorkManager.getInstance(context).enqueueUniquePeriodicWork(WATCHER_WORKER_TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
+ val work = PeriodicWorkRequest.Builder(ChannelsWatcher::class.java, 36, TimeUnit.HOURS, 12, TimeUnit.HOURS).addTag(TAG)
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
}
fun scheduleASAP(context: Context) {
- val work = OneTimeWorkRequest.Builder(ChannelsWatcher::class.java).addTag(WATCHER_WORKER_TAG).build()
- WorkManager.getInstance(context).enqueueUniqueWork(WATCHER_WORKER_TAG, ExistingWorkPolicy.REPLACE, work)
+ val work = OneTimeWorkRequest.Builder(ChannelsWatcher::class.java).addTag(TAG).build()
+ WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
}
fun cancel(context: Context): Operation {
- return WorkManager.getInstance(context).cancelAllWorkByTag(WATCHER_WORKER_TAG)
+ return WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
+
}
@Serializable
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt
new file mode 100644
index 000000000..f3379ee15
--- /dev/null
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/InflightPaymentsWatcher.kt
@@ -0,0 +1,275 @@
+/*
+ * Copyright 2024 ACINQ SAS
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package fr.acinq.phoenix.android.services
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import androidx.lifecycle.asFlow
+import androidx.work.CoroutineWorker
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.Operation
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import fr.acinq.bitcoin.TxId
+import fr.acinq.lightning.channel.states.Syncing
+import fr.acinq.lightning.utils.Connection
+import fr.acinq.phoenix.PhoenixBusiness
+import fr.acinq.phoenix.android.BuildConfig
+import fr.acinq.phoenix.android.PhoenixApplication
+import fr.acinq.phoenix.android.security.EncryptedSeed
+import fr.acinq.phoenix.android.security.SeedManager
+import fr.acinq.phoenix.android.utils.SystemNotificationHelper
+import fr.acinq.phoenix.android.utils.datastore.UserPrefs
+import fr.acinq.phoenix.data.LocalChannelInfo
+import fr.acinq.phoenix.data.StartupParams
+import fr.acinq.phoenix.data.inFlightPaymentsCount
+import fr.acinq.phoenix.legacy.utils.LegacyAppStatus
+import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore
+import fr.acinq.phoenix.managers.AppConfigurationManager
+import fr.acinq.phoenix.managers.AppConnectionsDaemon
+import fr.acinq.phoenix.utils.PlatformContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectIndexed
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.slf4j.LoggerFactory
+import java.util.concurrent.TimeUnit
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.toJavaDuration
+
+/**
+ * This worker starts a node to settle any pending in-flight payments. This will prevent payment timeouts
+ * (and channels force-close) in case the app is not started regularly and the silent push notifications
+ * sent by the ACINQ peer are ignored by the device.
+ *
+ * Example: devices using GrapheneOS, where FCM is not supported.
+ *
+ * This service is scheduled whenever there's a pending htlc in a channel.
+ * See [LocalChannelInfo.inFlightPaymentsCount].
+ */
+class InflightPaymentsWatcher(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) {
+
+ val log = LoggerFactory.getLogger(this::class.java)
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override suspend fun doWork(): Result {
+ log.info("starting in-flight-payments watcher")
+ var business: PhoenixBusiness? = null
+
+ try {
+
+ val internalData = (applicationContext as PhoenixApplication).internalDataRepository
+ val inFlightPaymentsCount = internalData.getInFlightPaymentsCount.first()
+
+ if (inFlightPaymentsCount == 0) {
+ log.info("expecting NO in-flight payments, terminating job...")
+ return Result.success()
+ } else {
+
+ // check various preferences -- this job may abort early
+ val legacyAppStatus = LegacyPrefsDatastore.getLegacyAppStatus(applicationContext).filterNotNull().first()
+ if (legacyAppStatus !is LegacyAppStatus.NotRequired) {
+ log.warn("aborting in-flight-payments check job, legacy_status=${legacyAppStatus.name()}")
+ return Result.success()
+ }
+
+ if (LegacyPrefsDatastore.getPrefsMigrationExpected(applicationContext).first() == true) {
+ log.warn("legacy data migration is required, aborting in-flight payment worker")
+ return Result.failure()
+ }
+
+ val encryptedSeed = SeedManager.loadSeedFromDisk(applicationContext) as? EncryptedSeed.V2.NoAuth ?: run {
+ log.error("unhandled seed type, aborting in-flight payment worker")
+ return Result.failure()
+ }
+
+ log.info("expecting $inFlightPaymentsCount in-flight payments, binding to service and starting process...")
+
+ // connect to [NodeService] to monitor the state of the main app business
+ val service = MutableStateFlow(null)
+ val serviceConnection = object : ServiceConnection {
+ override fun onServiceConnected(component: ComponentName, bind: IBinder) {
+ service.value = (bind as NodeService.NodeBinder).getService()
+ }
+
+ override fun onServiceDisconnected(component: ComponentName) {
+ service.value = null
+ }
+ }
+ Intent(applicationContext, NodeService::class.java).let { intent ->
+ applicationContext.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
+ }
+
+ // Start the monitoring process. If the main app starts, we interrupt this job to prevent concurrent access.
+ withContext(Dispatchers.Default) {
+ val stopJobs = MutableStateFlow(false)
+ var jobChannelsWatcher: Job? = null
+
+ val jobStateWatcher = launch {
+ service.filterNotNull().flatMapLatest { it.state.asFlow() }.collect { state ->
+ when (state) {
+ is NodeServiceState.Init, is NodeServiceState.Running, is NodeServiceState.Error, NodeServiceState.Disconnected -> {
+ log.info("node service in state=${state.name}, interrupting in-flight payments process")
+ stopJobs.value = true
+ scheduleOnce(applicationContext)
+ }
+
+ is NodeServiceState.Off -> {
+ // note: we can't simply launch NodeService, either as a background service (disallowed since android 8) or as a
+ // foreground service (disallowed since android 14)
+ log.info("node service in state=${state.name}, starting an isolated business")
+
+ jobChannelsWatcher = launch {
+ val mnemonics = encryptedSeed.decrypt()
+ business = startBusiness(mnemonics)
+
+ business?.connectionsManager?.connections?.first { it.global is Connection.ESTABLISHED }
+ log.info("connections established, watching channels for in-flight payments...")
+
+ business?.peerManager?.channelsFlow?.filterNotNull()?.collectIndexed { index, channels ->
+ val paymentsCount = channels.inFlightPaymentsCount()
+ internalData.saveInFlightPaymentsCount(paymentsCount)
+ when {
+ channels.isEmpty() -> {
+ log.info("no channels found, successfully terminating watcher (#$index)")
+ stopJobs.value = true
+ }
+
+ channels.any { it.value.state is Syncing } -> {
+ log.info("channels syncing, pausing 10s before next check (#$index)")
+ delay(10_000)
+ }
+
+ paymentsCount > 0 -> {
+ log.info("$paymentsCount payments in-flight, pausing 5s before next check (#$index)...")
+ delay(5_000)
+ }
+
+ else -> {
+ log.info("$paymentsCount payments in-flight, successfully terminating worker (#$index)...")
+ stopJobs.value = true
+ }
+ }
+ }
+ }.also {
+ it.invokeOnCompletion {
+ log.info("channels-watcher-job has been terminated (${it?.localizedMessage})")
+ }
+ }
+ }
+ }
+ }
+ }
+
+ val jobTimer = launch {
+ delay(120_000)
+ log.info("stopping channel-monitor job after 2 minutes without resolution, and show notification")
+ scheduleOnce(applicationContext)
+ SystemNotificationHelper.notifyInFlightHtlc(applicationContext)
+ stopJobs.value = true
+ }
+
+ stopJobs.first { it }
+ log.debug("stop-job signal detected")
+ jobChannelsWatcher?.cancelAndJoin()
+ jobStateWatcher.cancelAndJoin()
+ jobTimer.cancelAndJoin()
+ }
+ return Result.success()
+ }
+ } catch (e: Exception) {
+ log.error("error when processing in-flight-payments: ", e)
+ return Result.failure()
+ } finally {
+ business?.appConnectionsDaemon?.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.All)
+ business?.stop()
+ log.info("terminated in-flight-payments watcher process...")
+ }
+ }
+
+ private suspend fun startBusiness(mnemonics: ByteArray): PhoenixBusiness {
+ // retrieve preferences before starting business
+ val business = PhoenixBusiness(PlatformContext(applicationContext))
+ val electrumServer = UserPrefs.getElectrumServer(applicationContext).first()
+ val isTorEnabled = UserPrefs.getIsTorEnabled(applicationContext).first()
+ val liquidityPolicy = UserPrefs.getLiquidityPolicy(applicationContext).first()
+ val trustedSwapInTxs = LegacyPrefsDatastore.getMigrationTrustedSwapInTxs(applicationContext).first()
+ val preferredFiatCurrency = UserPrefs.getFiatCurrency(applicationContext).first()
+
+ // preparing business
+ val seed = business.walletManager.mnemonicsToSeed(EncryptedSeed.toMnemonics(mnemonics))
+ business.walletManager.loadWallet(seed)
+ business.appConfigurationManager.updateElectrumConfig(electrumServer)
+ business.appConfigurationManager.updatePreferredFiatCurrencies(
+ AppConfigurationManager.PreferredFiatCurrencies(primary = preferredFiatCurrency, others = emptySet())
+ )
+
+ // start business
+ business.start(
+ StartupParams(
+ requestCheckLegacyChannels = false,
+ isTorEnabled = isTorEnabled,
+ liquidityPolicy = liquidityPolicy,
+ trustedSwapInTxs = trustedSwapInTxs.map { TxId(it) }.toSet()
+ )
+ )
+
+ // start the swap-in wallet watcher
+ business.peerManager.getPeer().startWatchSwapInWallet()
+ return business
+ }
+
+ companion object {
+ private val log = LoggerFactory.getLogger(this::class.java)
+ const val TAG = BuildConfig.APPLICATION_ID + ".InflightPaymentsWatcher"
+
+ /** Schedule a in-flight payments watcher job to start every few hours. */
+ fun schedulePeriodic(context: Context) {
+ log.info("scheduling periodic in-flight-payments watcher")
+ val work = PeriodicWorkRequest.Builder(InflightPaymentsWatcher::class.java, 2, TimeUnit.HOURS, 3, TimeUnit.HOURS).addTag(TAG)
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, work.build())
+ }
+
+ /** Schedule an in-flight payments job to run once in [delay] from now (by default, 2 hours). Existing schedules are replaced. */
+ fun scheduleOnce(context: Context, delay: Duration = 2.hours) {
+ log.info("scheduling ${this::class.java.name} in $delay from now")
+ val work = OneTimeWorkRequestBuilder().setInitialDelay(delay.toJavaDuration()).build()
+ WorkManager.getInstance(context).enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, work)
+ }
+
+ /** Cancel all scheduled in-flight payments worker. */
+ fun cancel(context: Context): Operation {
+ return WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
+ }
+ }
+}
+
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt
index 55b27b8e6..f4fcb0939 100644
--- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/services/NodeService.kt
@@ -1,17 +1,21 @@
package fr.acinq.phoenix.android.services
+import android.app.Notification
import android.app.Service
import android.content.Intent
+import android.content.pm.ServiceInfo
import android.os.Binder
+import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.text.format.DateUtils
import androidx.compose.runtime.mutableStateListOf
import androidx.core.app.NotificationManagerCompat
+import androidx.core.app.ServiceCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
-import androidx.work.await
+import androidx.work.WorkManager
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging
import fr.acinq.bitcoin.TxId
@@ -30,6 +34,7 @@ import fr.acinq.phoenix.android.utils.SystemNotificationHelper
import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository
import fr.acinq.phoenix.android.utils.datastore.UserPrefs
import fr.acinq.phoenix.data.StartupParams
+import fr.acinq.phoenix.data.inFlightPaymentsCount
import fr.acinq.phoenix.legacy.utils.LegacyPrefsDatastore
import fr.acinq.phoenix.managers.AppConfigurationManager
import fr.acinq.phoenix.managers.CurrencyManager
@@ -44,8 +49,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.util.concurrent.locks.ReentrantLock
+import kotlin.time.Duration.Companion.hours
class NodeService : Service() {
@@ -75,6 +82,7 @@ class NodeService : Service() {
private var monitorPaymentsJob: Job? = null
private var monitorNodeEventsJob: Job? = null
private var monitorFcmTokenJob: Job? = null
+ private var monitorInFlightPaymentsJob: Job? = null
override fun onCreate() {
super.onCreate()
@@ -143,22 +151,26 @@ class NodeService : Service() {
/** Called when an intent is called for this service. */
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
- log.debug("start service from intent [ intent=$intent, flag=$flags, startId=$startId ]")
+ log.info("start service from intent [ intent=$intent, flag=$flags, startId=$startId ]")
val reason = intent?.getStringExtra(EXTRA_REASON)
+ fun startForeground(notif: Notification) {
+ ServiceCompat.startForeground(this, SystemNotificationHelper.HEADLESS_NOTIF_ID, notif, if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE else 0)
+ }
+
val encryptedSeed = SeedManager.loadSeedFromDisk(applicationContext)
when {
_state.value is NodeServiceState.Running -> {
// NOTE: the notification will NOT be shown if the app is already running
val notif = SystemNotificationHelper.notifyRunningHeadless(applicationContext)
- startForeground(SystemNotificationHelper.HEADLESS_NOTIF_ID, notif)
+ startForeground(notif)
}
encryptedSeed is EncryptedSeed.V2.NoAuth -> {
val seed = encryptedSeed.decrypt()
log.debug("successfully decrypted seed in the background, starting wallet...")
val notif = SystemNotificationHelper.notifyRunningHeadless(applicationContext)
- startForeground(SystemNotificationHelper.HEADLESS_NOTIF_ID, notif)
startBusiness(seed, requestCheckLegacyChannels = false)
+ startForeground(notif)
}
else -> {
log.warn("unhandled incoming payment with seed=${encryptedSeed?.name()} reason=$reason")
@@ -167,7 +179,7 @@ class NodeService : Service() {
"PendingSettlement" -> SystemNotificationHelper.notifyPendingSettlement(applicationContext)
else -> SystemNotificationHelper.notifyRunningHeadless(applicationContext)
}
- startForeground(SystemNotificationHelper.HEADLESS_NOTIF_ID, notif)
+ startForeground(notif)
}
}
shutdownHandler.removeCallbacksAndMessages(null)
@@ -204,7 +216,14 @@ class NodeService : Service() {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}) {
- ChannelsWatcher.cancel(applicationContext).await()
+ log.info("cancel competing workers")
+ val wm = WorkManager.getInstance(applicationContext)
+ withContext(Dispatchers.IO) {
+ wm.getWorkInfosByTag(InflightPaymentsWatcher.TAG).get() + wm.getWorkInfosByTag(ChannelsWatcher.TAG).get()
+ }.forEach {
+ wm.cancelWorkById(it.id).result.get()
+ }
+
log.info("starting node from service state=${_state.value?.name} with checkLegacyChannels=$requestCheckLegacyChannels")
doStartBusiness(decryptedMnemonics, requestCheckLegacyChannels)
ChannelsWatcher.schedule(applicationContext)
@@ -233,6 +252,7 @@ class NodeService : Service() {
monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.peerManager, business.currencyManager) }
monitorNodeEventsJob = serviceScope.launch { monitorNodeEvents(business.peerManager, business.nodeParamsManager) }
monitorFcmTokenJob = serviceScope.launch { monitorFcmToken(business) }
+ monitorInFlightPaymentsJob = serviceScope.launch { monitorInFlightPayments(business.peerManager) }
// preparing business
val seed = business.walletManager.mnemonicsToSeed(EncryptedSeed.toMnemonics(decryptedMnemonics))
@@ -333,6 +353,18 @@ class NodeService : Service() {
}
}
+ private suspend fun monitorInFlightPayments(peerManager: PeerManager) {
+ peerManager.channelsFlow.filterNotNull().collect {
+ val inFlightPaymentsCount = it.inFlightPaymentsCount()
+ internalData.saveInFlightPaymentsCount(inFlightPaymentsCount)
+ if (inFlightPaymentsCount == 0) {
+ InflightPaymentsWatcher.cancel(applicationContext)
+ } else {
+ InflightPaymentsWatcher.scheduleOnce(applicationContext, delay = 2.hours)
+ }
+ }
+ }
+
inner class NodeBinder : Binder() {
fun getService(): NodeService = this@NodeService
}
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt
index 1af1e8386..3822736ca 100644
--- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/SystemNotificationHelper.kt
@@ -230,6 +230,21 @@ object SystemNotificationHelper {
}
}
+ fun notifyInFlightHtlc(context: Context): Notification {
+ return NotificationCompat.Builder(context, SETTLEMENT_PENDING_NOTIF_CHANNEL).apply {
+ setContentTitle(context.getString(R.string.notif_inflight_payment_title))
+ setContentText(context.getString(R.string.notif_inflight_payment_message))
+ setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_inflight_payment_message)))
+ setSmallIcon(R.drawable.ic_phoenix_outline)
+ setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE))
+ setAutoCancel(true)
+ }.build().also {
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ NotificationManagerCompat.from(context).notify(SETTLEMENT_PENDING_NOTIF_ID, it)
+ }
+ }
+ }
+
suspend fun notifyPaymentsReceived(
context: Context,
paymentHash: ByteVector32,
diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt
index 7fba2736c..932a8d9a5 100644
--- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt
+++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt
@@ -53,6 +53,7 @@ class InternalDataRepository(private val internalData: DataStore) {
private val FCM_TOKEN = stringPreferencesKey("FCM_TOKEN")
private val CHANNELS_WATCHER_OUTCOME = stringPreferencesKey("CHANNELS_WATCHER_RESULT")
private val LAST_USED_SWAP_INDEX = intPreferencesKey("LAST_USED_SWAP_INDEX")
+ private val INFLIGHT_PAYMENTS_COUNT = intPreferencesKey("INFLIGHT_PAYMENTS_COUNT")
}
val log = LoggerFactory.getLogger(this::class.java)
@@ -132,4 +133,7 @@ class InternalDataRepository(private val internalData: DataStore) {
val getLastUsedSwapIndex: Flow = safeData.map { it[LAST_USED_SWAP_INDEX] ?: 0 }
suspend fun saveLastUsedSwapIndex(index: Int) = internalData.edit { it[LAST_USED_SWAP_INDEX] = index }
+ val getInFlightPaymentsCount: Flow = safeData.map { it[INFLIGHT_PAYMENTS_COUNT] ?: 0 }
+ suspend fun saveInFlightPaymentsCount(count: Int) = internalData.edit { it[INFLIGHT_PAYMENTS_COUNT] = count }
+
}
\ No newline at end of file
diff --git a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml
index 89e1310ff..3ac649ef7 100644
--- a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml
+++ b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml
@@ -27,6 +27,9 @@
Inicia Phoenix
Un pago entrante está pendiente.
+ Hay un pago pendiente.
+ Iniciar Phoenix para poder finalizar el pago a su debido tiempo.
+
Pasaste por alto un pago entrante
No se pudo iniciar Phoenix en segundo plano.
diff --git a/phoenix-android/src/main/res/values-cs/important_strings.xml b/phoenix-android/src/main/res/values-cs/important_strings.xml
index 3bc8dd9eb..1805cb707 100644
--- a/phoenix-android/src/main/res/values-cs/important_strings.xml
+++ b/phoenix-android/src/main/res/values-cs/important_strings.xml
@@ -30,6 +30,9 @@
Spusťte prosím Phoenix
Čeká se na příchozí vyrovnání.
+ Čeká se na platbu
+ Spusťte službu Phoenix, aby bylo možné platbu nakonec dokončit..
+
Zmeškaná příchozí platba
Nepodařilo se spustit Phoenix na pozadí.
diff --git a/phoenix-android/src/main/res/values-cs/strings.xml b/phoenix-android/src/main/res/values-cs/strings.xml
index 4645e8d5e..8431b24f2 100644
--- a/phoenix-android/src/main/res/values-cs/strings.xml
+++ b/phoenix-android/src/main/res/values-cs/strings.xml
@@ -22,7 +22,7 @@
Sledovač kanálů
Zobrazí se, když bude potřeba spustit Phoenix.
- Probíhá vyrovnání
+ Dokončení platby
Informuje vás, když je potřeba spustit Phoenix k vyrovnání platby.
Platba zamítnuta
diff --git a/phoenix-android/src/main/res/values-de/important_strings.xml b/phoenix-android/src/main/res/values-de/important_strings.xml
index 3e2bb94dd..bdae74b1b 100644
--- a/phoenix-android/src/main/res/values-de/important_strings.xml
+++ b/phoenix-android/src/main/res/values-de/important_strings.xml
@@ -30,6 +30,9 @@
Bitte öffnen Sie Phoenix
Eine eingehende Zahlung steht aus.
+ Eine Zahlung steht an
+ Starten Sie Phoenix, damit die Zahlung abgeschlossen werden kann.
+
Verpasste eingehende Zahlung
Phoenix konnte nicht im Hintergrund gestartet werden.
diff --git a/phoenix-android/src/main/res/values-de/strings.xml b/phoenix-android/src/main/res/values-de/strings.xml
index e083b6cfb..a4fbd588d 100644
--- a/phoenix-android/src/main/res/values-de/strings.xml
+++ b/phoenix-android/src/main/res/values-de/strings.xml
@@ -22,7 +22,7 @@
Kanal-Beobachtung
Wird angezeigt, wenn Sie Phoenix öffnen müssen.
- Zahlung ausstehend
+ Zahlung abschließen
Wird angezeigt, wenn Sie Phoenix öffnen müssen, um eine Zahlung abzuwickeln.
Zahlung abgelehnt
diff --git a/phoenix-android/src/main/res/values-es/important_strings.xml b/phoenix-android/src/main/res/values-es/important_strings.xml
index ddb5e049b..635cec9af 100644
--- a/phoenix-android/src/main/res/values-es/important_strings.xml
+++ b/phoenix-android/src/main/res/values-es/important_strings.xml
@@ -30,6 +30,9 @@
Por favor, inicie Phoenix
Está pendiente una liquidación.
+ Hay un pago pendiente.
+ Iniciar Phoenix para poder finalizar el pago a su debido tiempo.
+
Se ha producido un impago
Phoenix no pudo arrancar en segundo plano.
diff --git a/phoenix-android/src/main/res/values-fr/important_strings.xml b/phoenix-android/src/main/res/values-fr/important_strings.xml
index b6502044c..e109bfad7 100644
--- a/phoenix-android/src/main/res/values-fr/important_strings.xml
+++ b/phoenix-android/src/main/res/values-fr/important_strings.xml
@@ -30,6 +30,9 @@
Veuillez démarrer Phoenix
Un paiement en attente doit être finalisé.
+ Un paiement est en cours
+ Démarrez Phoenix pour que le paiement puisse à terme être finalisé.
+
Paiement entrant manqué
Phoenix n\'a pas pu démarrer en arrière plan.
diff --git a/phoenix-android/src/main/res/values-fr/strings.xml b/phoenix-android/src/main/res/values-fr/strings.xml
index 9e329fa72..105248b6e 100644
--- a/phoenix-android/src/main/res/values-fr/strings.xml
+++ b/phoenix-android/src/main/res/values-fr/strings.xml
@@ -22,8 +22,8 @@
Observation des canaux
Affiché lorsqu\'il faut lancer Phoenix.
- Paiement en suspens
- Affiché lorsque Phoenix doit être démarré pour terminer un paiement.
+ Finalisation de paiement
+ Affiché lorsque Phoenix doit être démarré pour finaliser un paiement.
Paiement rejeté
Affiché lorsqu\'un paiement est rejeté par insuffisance de liquidité.
diff --git a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml
index a71ee62c0..4e3a973d7 100644
--- a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml
+++ b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml
@@ -30,6 +30,9 @@
Por favor, inicie o Phoenix
Uma liquidação recebida está pendente.
+ Um pagamento está pendente
+ Iniciar a Phoenix para que o pagamento possa ser finalizado no devido tempo.
+
Pagamento recebido perdido
O Phoenix não pôde ser iniciado em segundo plano.
diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml
index 96ed5b187..00078928b 100644
--- a/phoenix-android/src/main/res/values/important_strings.xml
+++ b/phoenix-android/src/main/res/values/important_strings.xml
@@ -30,6 +30,9 @@
Please start Phoenix
An incoming settlement is pending.
+ A payment is pending
+ Start Phoenix so the payment can be finalised in due course.
+
Missed incoming payment
Phoenix was unable to start in the background.
diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml
index fb8972de6..a2d8d8421 100644
--- a/phoenix-android/src/main/res/values/strings.xml
+++ b/phoenix-android/src/main/res/values/strings.xml
@@ -22,8 +22,8 @@
Channels watcher
Shows up when you need to start Phoenix.
- Settlement pending
- Tells you when Phoenix needs to be started to settle a payment.
+ Payment finalisation
+ Tells you when Phoenix needs to be started to settle a pending payment.
Payment rejected
Shows up when Phoenix cannot receive a payment because of a liquidity issue.
@@ -292,6 +292,7 @@
Connecting…
Tor enabled
Request liquidity
+ You currently have %1$d payment(s) pending in your wallet.\n\nKeep the app open to make sure these payments settle properly without issues.
diff --git a/phoenix-ios/phoenix-ios/AppDelegate.swift b/phoenix-ios/phoenix-ios/AppDelegate.swift
index 8e8c03cbf..548386085 100644
--- a/phoenix-ios/phoenix-ios/AppDelegate.swift
+++ b/phoenix-ios/phoenix-ios/AppDelegate.swift
@@ -27,7 +27,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
private var appCancellables = Set()
private var groupPrefsCancellables = Set()
- private var isInBackground = false
public var externalLightningUrlPublisher = PassthroughSubject()
@@ -82,8 +81,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
FirebaseApp.configure()
Messaging.messaging().delegate = self
-
- WatchTower.registerBackgroundTasks()
let nc = NotificationCenter.default
@@ -95,14 +92,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
nc.publisher(for: UIApplication.willResignActiveNotification).sink { _ in
self._applicationWillResignActive(application)
}.store(in: &appCancellables)
-
- nc.publisher(for: UIApplication.didEnterBackgroundNotification).sink { _ in
- self._applicationDidEnterBackground(application)
- }.store(in: &appCancellables)
-
- nc.publisher(for: UIApplication.willEnterForegroundNotification).sink { _ in
- self._applicationWillEnterForeground(application)
- }.store(in: &appCancellables)
CrossProcessCommunication.shared.start(actor: .mainApp) { (_: XpcMessage) in
self.didReceivePaymentViaAppExtension()
@@ -119,12 +108,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
/// This function isn't called, because Firebase broke it with their stupid swizzling stuff.
func applicationWillResignActive(_ application: UIApplication) {/* :( */}
- /// This function isn't called, because Firebase broke it with their stupid swizzling stuff.
- func applicationDidEnterBackground(_ application: UIApplication) {/* :( */}
-
- /// This function isn't called, because Firebase broke it with their stupid swizzling stuff.
- func applicationWillEnterForeground(_ application: UIApplication) {/* :( */}
-
func _applicationDidBecomeActive(_ application: UIApplication) {
log.trace("### applicationDidBecomeActive(_:)")
@@ -153,30 +136,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate {
groupPrefsCancellables.removeAll()
}
- func _applicationDidEnterBackground(_ application: UIApplication) {
- log.trace("### applicationDidEnterBackground(_:)")
-
- if !isInBackground {
- Biz.business.appConnectionsDaemon?.incrementDisconnectCount(
- target: AppConnectionsDaemon.ControlTarget.companion.All
- )
- isInBackground = true
- }
-
- WatchTower.scheduleBackgroundTasks()
- }
-
- func _applicationWillEnterForeground(_ application: UIApplication) {
- log.trace("### applicationWillEnterForeground(_:)")
-
- if isInBackground {
- Biz.business.appConnectionsDaemon?.decrementDisconnectCount(
- target: AppConnectionsDaemon.ControlTarget.companion.All
- )
- isInBackground = false
- }
- }
-
// --------------------------------------------------
// MARK: UISceneSession Lifecycle
// --------------------------------------------------
diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift
index 64ff457d1..824e4a91d 100644
--- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift
+++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift
@@ -72,7 +72,7 @@ extension ConnectionsManager {
return connections.value_ as! Connections
}
- var asyncStream: AsyncStream {
+ func asyncStream() -> AsyncStream {
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
@@ -113,6 +113,30 @@ extension Connections {
}
return false
}
+
+ func targetsEstablished(_ target: AppConnectionsDaemon.ControlTarget) -> Bool {
+
+ if !self.internet.isEstablished() {
+ return false
+ }
+ if target.containsPeer {
+ if !self.peer.isEstablished() {
+ return false
+ }
+ }
+ if target.containsElectrum {
+ if !self.electrum.isEstablished() {
+ return false
+ }
+ }
+ if target.containsTor && self.torEnabled {
+ if !self.tor.isEstablished() {
+ return false
+ }
+ }
+
+ return true
+ }
}
extension LnurlAuth {
@@ -159,3 +183,18 @@ extension PlatformContext {
}
}
+extension AppConnectionsDaemon.ControlTargetCompanion {
+
+ var ElectrumPlusTor: AppConnectionsDaemon.ControlTarget {
+ return AppConnectionsDaemon.ControlTarget.companion.Electrum.plus(other: AppConnectionsDaemon.ControlTarget.companion.Tor
+ )
+ }
+
+ var AllMinusElectrum: AppConnectionsDaemon.ControlTarget {
+ var flags = AppConnectionsDaemon.ControlTarget.companion.All.flags
+ flags ^= AppConnectionsDaemon.ControlTarget.companion.Electrum.flags
+
+ return AppConnectionsDaemon.ControlTarget(flags: flags)
+ }
+}
+
diff --git a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift
index 41d0d63db..1b9ce8895 100644
--- a/phoenix-ios/phoenix-ios/officers/BusinessManager.swift
+++ b/phoenix-ios/phoenix-ios/officers/BusinessManager.swift
@@ -69,6 +69,8 @@ class BusinessManager {
///
public let srvExtConnectedToPeer = CurrentValueSubject(false)
+ private var isInBackground = false
+
private var walletInfo: WalletManager.WalletInfo? = nil
private var pushToken: String? = nil
private var fcmToken: String? = nil
@@ -77,6 +79,7 @@ class BusinessManager {
private var longLivedTasks = [String: UIBackgroundTaskIdentifier]()
private var paymentsPageFetchers = [String: PaymentsPageFetcher]()
+ private var appCancellables = Set()
private var cancellables = Set()
// --------------------------------------------------
@@ -87,6 +90,22 @@ class BusinessManager {
business = PhoenixBusiness(ctx: PlatformContext.default)
BusinessManager._isTestnet = business.chain.isTestnet()
+
+ let nc = NotificationCenter.default
+
+ nc.publisher(for: UIApplication.didFinishLaunchingNotification).sink { _ in
+ self.applicationDidFinishLaunching()
+ }.store(in: &appCancellables)
+
+ nc.publisher(for: UIApplication.didEnterBackgroundNotification).sink { _ in
+ self.applicationDidEnterBackground()
+ }.store(in: &appCancellables)
+
+ nc.publisher(for: UIApplication.willEnterForegroundNotification).sink { _ in
+ self.applicationWillEnterForeground()
+ }.store(in: &appCancellables)
+
+ WatchTower.shared.prepare()
}
// --------------------------------------------------
@@ -128,6 +147,7 @@ class BusinessManager {
business = PhoenixBusiness(ctx: PlatformContext.default)
syncManager = nil
swapInRejectedPublisher.send(nil)
+ canMergeChannelsForSplicingPublisher.send(false)
walletInfo = nil
peerConnectionState = nil
paymentsPageFetchers.removeAll()
@@ -310,13 +330,13 @@ class BusinessManager {
if isConnected && !wasConnected {
log.debug("incrementDisconnectCount(target: Peer)")
- Biz.business.appConnectionsDaemon?.incrementDisconnectCount(
+ self.business.appConnectionsDaemon?.incrementDisconnectCount(
target: AppConnectionsDaemon.ControlTarget.companion.Peer
)
} else if !isConnected && wasConnected {
log.debug("decrementDisconnectCount(target: Peer)")
- Biz.business.appConnectionsDaemon?.decrementDisconnectCount(
+ self.business.appConnectionsDaemon?.decrementDisconnectCount(
target: AppConnectionsDaemon.ControlTarget.companion.Peer
)
}
@@ -324,7 +344,7 @@ class BusinessManager {
}.store(in: &cancellables)
// Keep Prefs.shared.swapInAddressIndex up-to-date
- Biz.business.peerManager.peerStatePublisher()
+ business.peerManager.peerStatePublisher()
.flatMap { $0.swapInWallet.swapInAddressPublisher() }
.sink { (newInfo: Lightning_kmpSwapInWallet.SwapInAddressInfo?) in
@@ -357,6 +377,36 @@ class BusinessManager {
} //
}
+ // --------------------------------------------------
+ // MARK: Notifications
+ // --------------------------------------------------
+
+ func applicationDidFinishLaunching() {
+ log.trace("### applicationDidFinishLaunching()")
+ }
+
+ func applicationDidEnterBackground() {
+ log.trace("### applicationDidEnterBackground()")
+
+ if !isInBackground {
+ business.appConnectionsDaemon?.incrementDisconnectCount(
+ target: AppConnectionsDaemon.ControlTarget.companion.All
+ )
+ isInBackground = true
+ }
+ }
+
+ func applicationWillEnterForeground() {
+ log.trace("### applicationWillEnterForeground()")
+
+ if isInBackground {
+ business.appConnectionsDaemon?.decrementDisconnectCount(
+ target: AppConnectionsDaemon.ControlTarget.companion.All
+ )
+ isInBackground = false
+ }
+ }
+
// --------------------------------------------------
// MARK: Wallet
// --------------------------------------------------
diff --git a/phoenix-ios/phoenix-ios/officers/WatchTower.swift b/phoenix-ios/phoenix-ios/officers/WatchTower.swift
index a6f40989f..455c5087e 100644
--- a/phoenix-ios/phoenix-ios/officers/WatchTower.swift
+++ b/phoenix-ios/phoenix-ios/officers/WatchTower.swift
@@ -1,4 +1,4 @@
-import Foundation
+import UIKit
import BackgroundTasks
import Combine
import PhoenixShared
@@ -11,28 +11,133 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .trace)
fileprivate var log = LoggerFactory.shared.logger(filename, .warning)
#endif
+// The taskID must match the value in Info.plist
+fileprivate let taskId_watchTower = "co.acinq.phoenix.WatchTower"
+
+
class WatchTower {
- // The taskID must match the value in Info.plist
- private static let taskId_watchTower = "co.acinq.phoenix.WatchTower"
+ /// Singleton instance
+ public static let shared = WatchTower()
+
+ private var lastTaskFailed = false
+
+ private var appCancellables = Set()
+ private var cancellables = Set()
- public static func registerBackgroundTasks() -> Void {
- log.trace("registerWatchTowerTask()")
+ // --------------------------------------------------
+ // MARK: Init
+ // --------------------------------------------------
+
+ private init() { // must use shared instance
+
+ let nc = NotificationCenter.default
+
+ nc.publisher(for: UIApplication.didFinishLaunchingNotification).sink { _ in
+ self.applicationDidFinishLaunching()
+ }.store(in: &appCancellables)
+
+ nc.publisher(for: UIApplication.didEnterBackgroundNotification).sink { _ in
+ self.applicationDidEnterBackground()
+ }.store(in: &appCancellables)
+ }
+
+ func prepare() { // Stub function
+ }
+
+ // --------------------------------------------------
+ // MARK: Notifications
+ // --------------------------------------------------
+
+ func applicationDidFinishLaunching() {
+ log.trace("### applicationDidFinishLaunching()")
+
+ registerBackgroundTask()
+ }
+
+ func applicationDidEnterBackground() {
+ log.trace("### applicationDidEnterBackground()")
+
+ scheduleBackgroundTask()
+ }
+
+ // --------------------------------------------------
+ // MARK: Utilities
+ // --------------------------------------------------
+
+ private func hasInFlightTransactions(_ channels: [LocalChannelInfo]) -> Bool {
+ return channels.contains(where: { $0.inFlightPaymentsCount > 0 })
+ }
+
+ private func hasInFlightTransactions() -> Bool {
+ let channels = Biz.business.peerManager.channelsValue()
+ return hasInFlightTransactions(channels)
+ }
+
+ private func calculateRevokedChannelIds(
+ oldChannels: [Bitcoin_kmpByteVector32 : Lightning_kmpChannelState],
+ newChannels: [Bitcoin_kmpByteVector32 : Lightning_kmpChannelState]
+ ) -> Set {
+
+ var revokedChannelIds = Set()
+
+ for (channelId, oldChannel) in oldChannels {
+ if let newChannel = newChannels[channelId] {
+
+ var oldHasRevokedCommit = false
+ do {
+ var oldClosing: Lightning_kmpClosing? = oldChannel.asClosing()
+ if oldClosing == nil {
+ oldClosing = oldChannel.asOffline()?.state.asClosing()
+ }
+
+ if let oldClosing = oldClosing {
+ oldHasRevokedCommit = !oldClosing.revokedCommitPublished.isEmpty
+ }
+ }
+
+ var newHasRevokedCommit = false
+ do {
+ var newClosing: Lightning_kmpClosing? = newChannel.asClosing()
+ if newClosing == nil {
+ newClosing = newChannel.asOffline()?.state.asClosing()
+ }
+
+ if let newClosing = newChannel.asClosing() {
+ newHasRevokedCommit = !newClosing.revokedCommitPublished.isEmpty
+ }
+ }
+
+ if !oldHasRevokedCommit && newHasRevokedCommit {
+ revokedChannelIds.insert(channelId)
+ }
+ }
+ }
+
+ return revokedChannelIds
+ }
+
+ // --------------------------------------------------
+ // MARK: Task Management
+ // --------------------------------------------------
+
+ private func registerBackgroundTask() -> Void {
+ log.trace("registerBackgroundTask()")
BGTaskScheduler.shared.register(
forTaskWithIdentifier: taskId_watchTower,
using: DispatchQueue.main
) { (task) in
+ log.debug("BGTaskScheduler.executeTask()")
+
if let task = task as? BGAppRefreshTask {
- log.debug("BGTaskScheduler.executeTask: WatchTower")
-
- self.performWatchTowerTask(task)
+ self.performTask(task)
}
}
}
- public static func scheduleBackgroundTasks(soon: Bool = false) {
+ private func scheduleBackgroundTask() {
// As per the docs:
// > There can be a total of 1 refresh task and 10 processing tasks scheduled at any time.
@@ -40,14 +145,18 @@ class WatchTower {
let task = BGAppRefreshTaskRequest(identifier: taskId_watchTower)
- // As per WWDC talk (https://developer.apple.com/videos/play/wwdc2019/707):
- // It's recommended this value be a week or less.
- //
- if soon { // last attempt failed
- task.earliestBeginDate = Date(timeIntervalSinceNow: (60 * 60 * 4)) // 4 hours
+ if hasInFlightTransactions() {
+ task.earliestBeginDate = Date(timeIntervalSinceNow: (60 * 60 * 4)) // 2 hours
+
+ } else {
- } else { // last attempt succeeded
- task.earliestBeginDate = Date(timeIntervalSinceNow: (60 * 60 * 24 * 2)) // 2 days
+ if lastTaskFailed {
+ task.earliestBeginDate = Date(timeIntervalSinceNow: (60 * 60 * 4)) // 4 hours
+ } else {
+ // As per WWDC talk (https://developer.apple.com/videos/play/wwdc2019/707):
+ // It's recommended that this value be a week or less.
+ task.earliestBeginDate = Date(timeIntervalSinceNow: (60 * 60 * 24 * 2)) // 2 days
+ }
}
#if !targetEnvironment(simulator) // background tasks not available in simulator
@@ -60,133 +169,225 @@ class WatchTower {
#endif
}
+ // --------------------------------------------------
+ // MARK: Task Execution
+ // --------------------------------------------------
+
/// How to debug this:
/// https://www.andyibanez.com/posts/modern-background-tasks-ios13/
///
- private static func performWatchTowerTask(_ task: BGAppRefreshTask) -> Void {
- log.trace("performWatchTowerTask()")
+ private func performTask(_ task: BGAppRefreshTask) -> Void {
+ log.trace("performTask()")
- // kotlin will crash below if we attempt to run this code on non-main thread
+ // Kotlin will crash below if we attempt to run this code on non-main thread
assertMainThread()
+ // There are 2 tasks we may need to perform:
+ //
+ // 1) WatchTower task
+ //
+ // If our channel partner attempts to cheat, and broadcasts a revoked transaction,
+ // then our WatchTower task will spot the TX, issue a penalty TX, and collect
+ // all the funds in the channel.
+ //
+ // Since we use a relatively long `to_self_delay` (2016 blocks ≈ 14 days),
+ // this gives us plenty of time to catch a cheater.
+ //
+ // For more information, see (in lightning-kmp project):
+ // - NodeParams.toRemoteDelayBlocks
+ // - NodeParams.maxToLocalDelayBlocks
+ //
+ // 2) PendingTxHandler task
+ //
+ // Transactions may become stuck in the network, and we want to ensure that our node
+ // comes online to properly cancel the TX before it times out and forces a channel to close.
+ //
+ // So when we have pending Tx's, we need to connect to the server, and update our state(s).
+
+ let _true = Date.distantPast < Date.now
+ // ^^^^^^ dear compiler,
+ // stop emitting warnings for variables that might be changed for debugging, thanks
+
+ #if DEBUG
+ let performWatchTowerTask = _true // only disable for debugging purposes
+ #else
+ let performWatchTowerTask = _true // always perform this task
+ #endif
+ let performPendingTxTask = hasInFlightTransactions()
+ let performBothTasks = performWatchTowerTask && performPendingTxTask
+
let business = Biz.business
let appConnectionsDaemon = business.appConnectionsDaemon
- let electrumTarget = AppConnectionsDaemon.ControlTarget.companion.Electrum
+
+ let target: AppConnectionsDaemon.ControlTarget
+ if (performBothTasks || !performWatchTowerTask) {
+ target = AppConnectionsDaemon.ControlTarget.companion.All
+ } else if !performWatchTowerTask {
+ target = AppConnectionsDaemon.ControlTarget.companion.AllMinusElectrum
+ } else {
+ target = AppConnectionsDaemon.ControlTarget.companion.ElectrumPlusTor
+ }
var didDecrement = false
- var upToDateListener: AnyCancellable? = nil
+ var watchTowerListener: AnyCancellable? = nil
+ var pendingTxHandler: Task? = nil
var peer: Lightning_kmpPeer? = nil
var oldChannels = [Bitcoin_kmpByteVector32 : Lightning_kmpChannelState]()
- let cleanup = {(success: Bool) in
+ let cleanup = {(didTimeout: Bool) in
+ log.debug("cleanup()")
if didDecrement { // need to balance decrement call
- appConnectionsDaemon?.incrementDisconnectCount(target: electrumTarget)
- }
- upToDateListener?.cancel()
-
- let newChannels = peer?.channels ?? [:]
- var revokedChannelIds = Set()
-
- for (channelId, oldChannel) in oldChannels {
- if let newChannel = newChannels[channelId] {
-
- var oldHasRevokedCommit = false
- do {
- var oldClosing: Lightning_kmpClosing? = oldChannel.asClosing()
- if oldClosing == nil {
- oldClosing = oldChannel.asOffline()?.state.asClosing()
- }
-
- if let oldClosing = oldClosing {
- oldHasRevokedCommit = !oldClosing.revokedCommitPublished.isEmpty
- }
- }
-
- var newHasRevokedCommit = false
- do {
- var newClosing: Lightning_kmpClosing? = newChannel.asClosing()
- if newClosing == nil {
- newClosing = newChannel.asOffline()?.state.asClosing()
- }
-
- if let newClosing = newChannel.asClosing() {
- newHasRevokedCommit = !newClosing.revokedCommitPublished.isEmpty
- }
- }
-
- if !oldHasRevokedCommit && newHasRevokedCommit {
- revokedChannelIds.insert(channelId)
- }
- }
+ appConnectionsDaemon?.incrementDisconnectCount(target: target)
}
+
+ watchTowerListener?.cancel()
+ pendingTxHandler?.cancel()
- self.scheduleBackgroundTasks(soon: success ? false : true)
+ self.lastTaskFailed = didTimeout
+ self.scheduleBackgroundTask()
- if !revokedChannelIds.isEmpty {
- // One or more channels were force-closed, and we discovered the revoked commit(s) !
-
- NotificationsManager.shared.displayLocalNotification_revokedCommit()
-
- let outcome = WatchTowerOutcome.RevokedFound(channels: revokedChannelIds)
- business.notificationsManager.saveWatchTowerOutcome(outcome: outcome) { _ in
- task.setTaskCompleted(success: success)
- }
+ if performWatchTowerTask {
- } else if success {
- // WatchTower completed successfully, and no cheating by the other party was found.
+ let newChannels = peer?.channels ?? [:]
+ let revokedChannelIds = self.calculateRevokedChannelIds(
+ oldChannels: oldChannels,
+ newChannels: newChannels
+ )
- let outcome = WatchTowerOutcome.Nominal(channelsWatchedCount: Int32(newChannels.count))
- business.notificationsManager.saveWatchTowerOutcome(outcome: outcome) { _ in
- task.setTaskCompleted(success: success)
+ if !revokedChannelIds.isEmpty {
+ // One or more channels were force-closed, and we discovered the revoked commit(s) !
+
+ NotificationsManager.shared.displayLocalNotification_revokedCommit()
+
+ let outcome = WatchTowerOutcome.RevokedFound(channels: revokedChannelIds)
+ business.notificationsManager.saveWatchTowerOutcome(outcome: outcome) { _ in
+ task.setTaskCompleted(success: true)
+ }
+
+ } else if !didTimeout {
+ // WatchTower completed successfully, and no cheating by the other party was found.
+
+ let outcome = WatchTowerOutcome.Nominal(channelsWatchedCount: Int32(newChannels.count))
+ business.notificationsManager.saveWatchTowerOutcome(outcome: outcome) { _ in
+ task.setTaskCompleted(success: true)
+ }
+
+ } else {
+ // The BGAppRefreshTask timed out (iOS only gives us ~30 seconds)
+
+ let outcome = WatchTowerOutcome.Unknown()
+ business.notificationsManager.saveWatchTowerOutcome(outcome: outcome) { _ in
+ task.setTaskCompleted(success: false)
+ }
}
} else {
- // The BGAppRefreshTask timed out (iOS only gives us ~30 seconds)
- let outcome = WatchTowerOutcome.Unknown()
- business.notificationsManager.saveWatchTowerOutcome(outcome: outcome) { _ in
- task.setTaskCompleted(success: success)
+ task.setTaskCompleted(success: !didTimeout)
+ }
+ }
+
+ var finishedWatchTowerTask = performWatchTowerTask ? false : true
+ var finishedPendingTxTask = performPendingTxTask ? false : true
+
+ let maybeCleanup = {(didTimeout: Bool) in
+ if (finishedWatchTowerTask && finishedPendingTxTask) {
+ cleanup(didTimeout)
+ }
+ }
+
+ let finishWatchTowerTask = {(didTimeout: Bool) in
+ DispatchQueue.main.async {
+ if !finishedWatchTowerTask {
+ finishedWatchTowerTask = true
+ log.debug("finishWatchTowerTask()")
+ maybeCleanup(didTimeout)
}
}
}
- var isFinished = false
- let finishTask = {(success: Bool) in
-
+ let finishPendingTxTask = {(didTimeout: Bool) in
DispatchQueue.main.async {
- if !isFinished {
- isFinished = true
- cleanup(success)
+ if !finishedPendingTxTask {
+ finishedPendingTxTask = true
+ log.debug("finishPendingTxTask()")
+ maybeCleanup(didTimeout)
}
}
}
+ let abortTasks = {(didTimeout: Bool) in
+ finishWatchTowerTask(didTimeout)
+ finishPendingTxTask(didTimeout)
+ }
+
task.expirationHandler = {
- finishTask(false)
+ abortTasks(/* didTimeout: */ true)
+ }
+
+ guard (performWatchTowerTask || performPendingTxTask) else {
+ return abortTasks(/* didTimeout: */ false)
}
peer = business.peerManager.peerStateValue()
guard let _peer = peer else {
- // If there's not a peer, then the wallet is locked.
- return finishTask(true)
+ // If there's not a peer, then there's nothing to do
+ return abortTasks(/* didTimeout: */ false)
}
oldChannels = _peer.channels
guard oldChannels.count > 0 else {
- // We don't have any channels, so there's nothing to watch.
- return finishTask(true)
+ // If we don't have any channels, then there's nothing to do
+ return abortTasks(/* didTimeout: */ false)
}
- appConnectionsDaemon?.decrementDisconnectCount(target: electrumTarget)
+ appConnectionsDaemon?.decrementDisconnectCount(target: target)
didDecrement = true
- // We setup a handler so we know when the WatchTower task has completed.
- // I.e. when the channel subscriptions are considered up-to-date.
+ if performWatchTowerTask {
+ // We setup a handler so we know when the WatchTower task has completed.
+ // I.e. when the channel subscriptions are considered up-to-date.
+
+ let minMillis = Date.now.toMilliseconds()
+ watchTowerListener = _peer.watcher.upToDatePublisher().sink { (millis: Int64) in
+ // millis => timestamp of when electrum watch was marked up-to-date
+ if millis > minMillis {
+ finishWatchTowerTask(/* didTimeout: */ false)
+ }
+ }
+ }
- upToDateListener = _peer.watcher.upToDatePublisher().sink { (millis: Int64) in
- finishTask(true)
+ if performPendingTxTask {
+ pendingTxHandler = Task { @MainActor in
+
+ // Wait until we're connected
+ for try await connections in Biz.business.connectionsManager.asyncStream() {
+ if connections.targetsEstablished(target) {
+ break
+ }
+ }
+
+ // Give the peer a max of 10 seconds to perform any needed tasks
+ async let subtask1 = Task { @MainActor in
+ try await Task.sleep(seconds: 10)
+ finishPendingTxTask(/* didTimeout: */ false)
+ }
+
+ // Check to see if the peer clears its pending TX's
+ async let subtask2 = Task { @MainActor in
+ for try await channels in Biz.business.peerManager.channelsPublisher().values {
+ if !hasInFlightTransactions(channels) {
+ break
+ }
+ }
+ try await Task.sleep(seconds: 2) // a bit of cleanup time
+ finishPendingTxTask(/* didTimeout: */ false)
+ }
+
+ let _ = await [subtask1, subtask2]
+ }
}
}
}
diff --git a/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift b/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift
index 0b6ea886d..e76fcdb37 100644
--- a/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift
+++ b/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift
@@ -54,7 +54,8 @@ class NotificationService: UNNotificationServiceExtension {
let selfPtr = Unmanaged.passUnretained(self).toOpaque().debugDescription
log.trace("instance => \(selfPtr)")
- log.trace("didReceive(_:withContentHandler:)")
+ log.trace("didReceive(request:withContentHandler:)")
+ log.trace("request.content.userInfo: \(request.content.userInfo)")
self.contentHandler = contentHandler
self.bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
@@ -274,6 +275,42 @@ class NotificationService: UNNotificationServiceExtension {
// MARK: Finish
// --------------------------------------------------
+ enum PushNotificationReason {
+ case incomingPayment
+ case pendingSettlement
+ case unknown
+ }
+
+ private func pushNotificationReason() -> PushNotificationReason {
+
+ // Example: request.content.userInfo:
+ // {
+ // "gcm.message_id": 1605136272123442,
+ // "google.c.sender.id": 458618232423,
+ // "google.c.a.e": 1,
+ // "google.c.fid": "dRLLO-mxUxbDvmV1urj5Tt",
+ // "reason": "IncomingPayment",
+ // "aps": {
+ // "alert": {
+ // "title": "Phoenix is running in the background",
+ // },
+ // "mutable-content": 1
+ // }
+ // }
+
+ if let userInfo = bestAttemptContent?.userInfo,
+ let reason = userInfo["reason"] as? String
+ {
+ switch reason {
+ case "IncomingPayment" : return .incomingPayment
+ case "PendingSettlement" : return .pendingSettlement
+ default : break
+ }
+ }
+
+ return .unknown
+ }
+
private func displayPushNotification() {
log.trace("displayPushNotification()")
assertMainThread()
@@ -300,7 +337,13 @@ class NotificationService: UNNotificationServiceExtension {
stopPhoenix()
if receivedPayments.isEmpty {
- bestAttemptContent.title = NSLocalizedString("Missed incoming payment", comment: "")
+
+ if pushNotificationReason() == .pendingSettlement {
+ bestAttemptContent.title = NSLocalizedString("Please start Phoenix", comment: "")
+ bestAttemptContent.body = NSLocalizedString("An incoming settlement is pending.", comment: "")
+ } else {
+ bestAttemptContent.title = NSLocalizedString("Missed incoming payment", comment: "")
+ }
} else { // received 1 or more payments
diff --git a/phoenix-legacy/src/main/AndroidManifest.xml b/phoenix-legacy/src/main/AndroidManifest.xml
index 1e8de8c1e..7a3f56bfa 100644
--- a/phoenix-legacy/src/main/AndroidManifest.xml
+++ b/phoenix-legacy/src/main/AndroidManifest.xml
@@ -22,6 +22,7 @@
+
emptyList()
}
}
+ /** Returns the count of payments being sent or received by this channel. */
+ val inFlightPaymentsCount: Int by lazy {
+ when (state) {
+ is ChannelStateWithCommitments -> {
+ buildSet {
+ state.commitments.latest.localCommit.spec.htlcs.forEach { add(it.add.paymentHash) }
+ state.commitments.latest.remoteCommit.spec.htlcs.forEach { add(it.add.paymentHash) }
+ state.commitments.latest.nextRemoteCommit?.commit?.spec?.htlcs?.forEach { add(it.add.paymentHash) }
+ }.size
+ }
+ else -> 0
+ }
+ }
/** The channel's data serialized in a json string. */
val json: String by lazy { JsonSerializers.json.encodeToString(state) }
@@ -156,3 +169,7 @@ fun Map?.canRequestLiquidity(): Boolean {
return this?.values?.any { it.isUsable } ?: false
}
+/** Liquidity can be requested if you have at least 1 usable channel. */
+fun Map?.inFlightPaymentsCount(): Int {
+ return this?.values?.sumOf { it.inFlightPaymentsCount } ?: 0
+}
diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConnectionsDaemon.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConnectionsDaemon.kt
index c66cc20cc..fb0062dfd 100644
--- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConnectionsDaemon.kt
+++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/AppConnectionsDaemon.kt
@@ -147,7 +147,7 @@ class AppConnectionsDaemon(
controlChanges.consumeEach { change ->
val newState = controlFlow.value.change()
if (newState.walletIsAvailable && (label == "peer" || label == "electrum")) {
- logger.info { "$label $newState" }
+ logger.debug { "$label $newState" }
}
controlFlow.value = newState
}