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
}