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 }