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 }