Skip to content

Commit

Permalink
Settle in-flight payments in the background (#522)
Browse files Browse the repository at this point in the history
In-flight payments may time out and trigger channels' closures
if the app does not connect to the peer in time. Usually this is
taken care of by FCM silent push notifications sent by the peer.
However these notifications may fail.

To fix this edge case, Phoenix now schedules background tasks
connecting to the peer whenever there are pending payments.

On iOS, this job may run for 30 seconds, on Android 2 minutes.

On Android, we also display a badge in the Home screen that
counts pending htlcs.

---------

Co-authored-by: Robbie Hanson <[email protected]>
Co-authored-by: dluvian <[email protected]>
  • Loading branch information
3 people authored Feb 29, 2024
1 parent 22d38a5 commit dc0b348
Show file tree
Hide file tree
Showing 31 changed files with 960 additions and 221 deletions.
2 changes: 2 additions & 0 deletions phoenix-android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
Expand Down Expand Up @@ -85,6 +86,7 @@
<!-- node service -->
<service
android:name="fr.acinq.phoenix.android.services.NodeService"
android:foregroundServiceType="shortService"
android:exported="false"
android:stopWithTask="false" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -62,70 +62,34 @@ 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()
.padding(horizontal = 8.dp)
.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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit dc0b348

Please sign in to comment.