Skip to content

Commit

Permalink
Support new on-the-fly funding (#632)
Browse files Browse the repository at this point in the history
Now using lightning-kmp 1.8.0.

- Updated the liquidity policy (see NodeParamsManager). We
are using a policy that does not target additional liquidity
and that does not use fee credit yet.

- Updated the received-with database object with new types
- Updated the liquidity-purchase database objects with new
purchase & payment-details types. The lease data are now
legacy and are removed wherever possible.

- Updated the iOS and Android payment screens for liquidity 
purchases. Liquidity events are now stored as separate payments.
These payments may be linked to incoming payments, if so
a button is added in the liquidity screen to navigate to these
payments.

- (android) Completed the Portuguese (BR) translation.

---------

Co-authored-by: Robbie Hanson <304604+robbiehanson@users.noreply.github.com>
dpad85 and robbiehanson authored Oct 4, 2024
1 parent 46397f8 commit 5fbe04e
Showing 124 changed files with 4,417 additions and 2,165 deletions.
4 changes: 2 additions & 2 deletions buildSrc/src/main/kotlin/Versions.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
object Versions {
const val lightningKmp = "1.7.3"
const val lightningKmp = "1.8.0"
const val secp256k1 = "0.14.0"
const val torMobile = "0.2.0"

@@ -18,7 +18,7 @@ object Versions {
const val lifecycle = "2.6.0"
const val prefs = "1.2.0"
const val datastore = "1.0.0"
const val compose = "1.6.2"
const val compose = "1.6.8"
const val composeCompiler = "1.5.8"
const val navCompose = "2.6.0"
const val accompanist = "0.30.1"
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/*
* 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.components

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomSheetDialog(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
skipPartiallyExpanded: Boolean = true,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
scrimAlpha: Float = 0.2f,
internalPadding: PaddingValues = PaddingValues(top = 0.dp, start = 20.dp, end = 20.dp, bottom = 64.dp),
content: @Composable ColumnScope.() -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = {
// executed when user click outside the sheet, and after sheet has been hidden thru state.
onDismiss()
},
modifier = modifier,
containerColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.onSurface,
scrimColor = MaterialTheme.colors.onBackground.copy(alpha = scrimAlpha),
) {
Column(
horizontalAlignment = horizontalAlignment,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.padding(internalPadding)
) {
content()
}
}
}
Original file line number Diff line number Diff line change
@@ -336,11 +336,13 @@ fun Button(
}
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Clickable(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
onLongClick: (() -> Unit)? = null,
textStyle: TextStyle = MaterialTheme.typography.button,
backgroundColor: Color = Color.Unspecified, // transparent by default!
shape: Shape = RectangleShape,
@@ -360,8 +362,11 @@ fun Clickable(
elevation = 0.dp,
modifier = modifier
.clip(shape)
.clickable(
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = null,
onDoubleClick = null,
enabled = enabled,
role = Role.Button,
onClickLabel = clickDescription,
@@ -422,7 +427,7 @@ fun AddressLinkButton(
}

@Composable
fun TransactionLinkButton(
fun InlineTransactionLink(
modifier: Modifier = Modifier,
txId: TxId,
) {
Original file line number Diff line number Diff line change
@@ -179,7 +179,7 @@ fun RowScope.IconPopup(
popupLink: Pair<String, String>? = null,
spaceLeft: Dp? = 8.dp,
spaceRight: Dp? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
var showPopup by remember { mutableStateOf(false) }
spaceLeft?.let { Spacer(Modifier.requiredWidth(it)) }
@@ -191,7 +191,7 @@ fun RowScope.IconPopup(
padding = PaddingValues(iconPadding),
modifier = modifier.requiredSize(iconSize),
interactionSource = interactionSource,
onClick = { showPopup = true }
onClick = { showPopup = true },
)
if (showPopup) {
PopupDialog(onDismiss = { showPopup = false }, message = popupMessage, button = popupLink?.let { (text, link) ->
Original file line number Diff line number Diff line change
@@ -23,7 +23,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
@@ -34,7 +33,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -105,8 +103,8 @@ fun SplashLayout(
}
Column(
modifier = Modifier
.widthIn(max = 500.dp)
.padding(horizontal = 24.dp),
.widthIn(max = 700.dp)
.padding(horizontal = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
bottomContent()
@@ -127,27 +125,30 @@ fun SplashLabelRow(
) {
Row {
Row(
modifier = Modifier.weight(1f).alignByBaseline(),
modifier = Modifier
.weight(1f)
.heightIn(min = 22.dp)
.alignByBaseline(),
horizontalArrangement = Arrangement.End
) {
Spacer(modifier = Modifier.weight(1f))
if (helpMessage != null) {
IconPopup(modifier = Modifier.offset(y = (-3).dp), popupMessage = helpMessage, popupLink = helpLink, spaceLeft = 0.dp, spaceRight = 5.dp)
}
Text(
text = label.uppercase(),
style = MaterialTheme.typography.subtitle1.copy(fontSize = 12.sp, textAlign = TextAlign.End),
style = MaterialTheme.typography.subtitle1.copy(fontSize = 12.sp),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (helpMessage != null) {
IconPopup(modifier = Modifier.offset(y = (-3).dp), popupMessage = helpMessage, popupLink = helpLink, spaceLeft = 4.dp, spaceRight = 0.dp)
}
if (icon != null) {
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(3.dp))
Image(
painter = painterResource(id = icon),
colorFilter = ColorFilter.tint(iconTint),
contentDescription = null,
modifier = Modifier
.size(ButtonDefaults.IconSize)
.size(17.dp)
.offset(y = (-2).dp)
)
}
@@ -175,7 +176,7 @@ fun SplashClickableContent(
.offset(x = (-8).dp),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(8.dp)) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
content()
}
}
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@ import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.channel.ChannelFundingResponse
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.phoenix.managers.PeerManager
@@ -42,7 +43,7 @@ sealed class CpfpState {
data class Executing(val actualFeerate: FeeratePerKw) : CpfpState()
sealed class Complete : CpfpState() {
object Success: Complete()
data class Failed(val failure: ChannelCommand.Commitment.Splice.Response.Failure): Complete()
data class Failed(val failure: ChannelFundingResponse.Failure): Complete()
}
sealed class Error: CpfpState() {
data class Thrown(val e: Throwable): Error()
@@ -102,11 +103,11 @@ class CpfpViewModel(val peerManager: PeerManager) : ViewModel() {
log.info("failed to execute cpfp splice: assuming no channels")
state = CpfpState.Error.NoChannels
}
is ChannelCommand.Commitment.Splice.Response.Created -> {
is ChannelFundingResponse.Success -> {
log.info("successfully executed cpfp splice: $res")
state = CpfpState.Complete.Success
}
is ChannelCommand.Commitment.Splice.Response.Failure -> {
is ChannelFundingResponse.Failure -> {
log.info("failed to execute cpfp splice: $res")
state = CpfpState.Complete.Failed(res)
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -33,6 +33,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.db.*
import fr.acinq.lightning.payment.Bolt11Invoice
@@ -42,24 +43,33 @@ import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sum
import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.LocalFiatCurrency
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.Screen
import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.AmountView
import fr.acinq.phoenix.android.components.Card
import fr.acinq.phoenix.android.components.CardHeader
import fr.acinq.phoenix.android.components.Clickable
import fr.acinq.phoenix.android.components.TextWithIcon
import fr.acinq.phoenix.android.components.TransactionLinkButton
import fr.acinq.phoenix.android.components.InlineTransactionLink
import fr.acinq.phoenix.android.components.openLink
import fr.acinq.phoenix.android.components.txUrl
import fr.acinq.phoenix.android.fiatRate
import fr.acinq.phoenix.android.navController
import fr.acinq.phoenix.android.navigateToPaymentDetails
import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString
import fr.acinq.phoenix.android.utils.Converter.toFiat
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.MSatDisplayPolicy
import fr.acinq.phoenix.android.utils.copyToClipboard
import fr.acinq.phoenix.android.utils.mutedBgColor
import fr.acinq.phoenix.data.ExchangeRate
import fr.acinq.phoenix.data.WalletPaymentInfo
import fr.acinq.phoenix.utils.extensions.amountFeeCredit
import fr.acinq.phoenix.utils.extensions.relatedPaymentIds


@Composable
@@ -92,6 +102,7 @@ fun PaymentDetailsTechnicalView(
is IncomingPayment.ReceivedWith.LightningPayment -> ReceivedWithLightning(it, rateThen)
is IncomingPayment.ReceivedWith.NewChannel -> ReceivedWithNewChannel(it, rateThen)
is IncomingPayment.ReceivedWith.SpliceIn -> ReceivedWithSpliceIn(it, rateThen)
is IncomingPayment.ReceivedWith.AddedToFeeCredit -> ReceivedWithFeeCredit(it, rateThen)
}
}
}
@@ -180,7 +191,7 @@ private fun HeaderForIncoming(
// -- payment type
TechnicalRow(label = stringResource(id = R.string.paymentdetails_payment_type_label)) {
Text(
when (payment.origin) {
text = when (payment.origin) {
is IncomingPayment.Origin.Invoice -> stringResource(R.string.paymentdetails_normal_incoming)
is IncomingPayment.Origin.SwapIn -> stringResource(R.string.paymentdetails_swapin)
is IncomingPayment.Origin.OnChain -> stringResource(R.string.paymentdetails_swapin)
@@ -233,7 +244,7 @@ private fun AmountSection(
is InboundLiquidityOutgoingPayment -> {
TechnicalRowAmount(
label = stringResource(id = R.string.paymentdetails_liquidity_amount_label),
amount = payment.lease.amount.toMilliSatoshi(),
amount = payment.purchase.amount.toMilliSatoshi(),
rateThen = rateThen,
mSatDisplayPolicy = MSatDisplayPolicy.SHOW
)
@@ -245,14 +256,10 @@ private fun AmountSection(
)
TechnicalRowAmount(
label = stringResource(id = R.string.paymentdetails_liquidity_service_fee_label),
amount = payment.lease.fees.serviceFee.toMilliSatoshi(),
amount = payment.purchase.fees.serviceFee.toMilliSatoshi(),
rateThen = rateThen,
mSatDisplayPolicy = MSatDisplayPolicy.SHOW
)
TechnicalRowSelectable(
label = stringResource(id = R.string.paymentdetails_liquidity_signature_label),
value = payment.lease.sellerSig.toHex(),
)
}
is OutgoingPayment -> {
TechnicalRowAmount(
@@ -275,6 +282,14 @@ private fun AmountSection(
rateThen = rateThen,
mSatDisplayPolicy = MSatDisplayPolicy.SHOW
)
payment.amountFeeCredit?.let {
TechnicalRowAmount(
label = stringResource(R.string.paymentdetails_amount_fee_credit_label),
amount = it,
rateThen = rateThen,
mSatDisplayPolicy = MSatDisplayPolicy.SHOW
)
}
val receivedWithNewChannel = payment.received?.receivedWith?.filterIsInstance<IncomingPayment.ReceivedWith.NewChannel>() ?: emptyList()
val receivedWithSpliceIn = payment.received?.receivedWith?.filterIsInstance<IncomingPayment.ReceivedWith.SpliceIn>() ?: emptyList()
if ((receivedWithNewChannel + receivedWithSpliceIn).isNotEmpty()) {
@@ -337,18 +352,12 @@ private fun DetailsForLightningOutgoingPayment(
private fun DetailsForChannelClose(
payment: ChannelCloseOutgoingPayment
) {
TechnicalRowSelectable(
label = stringResource(id = R.string.paymentdetails_channel_id_label),
value = payment.channelId.toHex()
)
ChannelIdRow(payment.channelId)
TechnicalRowSelectable(
label = stringResource(id = R.string.paymentdetails_bitcoin_address_label),
value = payment.address
)
TechnicalRow(
label = stringResource(id = R.string.paymentdetails_tx_id_label),
content = { TransactionLinkButton(txId = payment.txId) }
)
TransactionRow(payment.txId)
TechnicalRowSelectable(
label = stringResource(id = R.string.paymentdetails_closing_type_label),
value = when (payment.closingType) {
@@ -365,43 +374,50 @@ private fun DetailsForChannelClose(
private fun DetailsForCpfp(
payment: SpliceCpfpOutgoingPayment
) {
TechnicalRow(
label = stringResource(id = R.string.paymentdetails_tx_id_label),
content = { TransactionLinkButton(txId = payment.txId) }
)
TransactionRow(payment.txId)
}

@Composable
private fun DetailsForInboundLiquidity(
payment: InboundLiquidityOutgoingPayment
) {
TechnicalRow(
label = stringResource(id = R.string.paymentdetails_tx_id_label),
content = { TransactionLinkButton(txId = payment.txId) }
)
TechnicalRowSelectable(
label = stringResource(id = R.string.paymentdetails_channel_id_label),
value = payment.channelId.toHex(),
)
TechnicalRow(label = stringResource(id = R.string.paymentdetails_liquidity_purchase_type)) {
Text(text = "${
when (payment.purchase) {
is LiquidityAds.Purchase.Standard -> "Standard"
is LiquidityAds.Purchase.WithFeeCredit -> "Fee credit"
}
} [${payment.purchase.paymentDetails.paymentType}]")
}
TransactionRow(payment.txId)
ChannelIdRow(channelId = payment.channelId)
val paymentIds = payment.relatedPaymentIds()
val navController = navController
paymentIds.forEach {
TechnicalRowClickable(
label = stringResource(id = R.string.paymentdetails_liquidity_caused_by_label),
onClick = { navigateToPaymentDetails(navController, it, isFromEvent = false) },
) {
TextWithIcon(
text = "(incoming) ${it.dbId}",
icon = R.drawable.ic_arrow_down_circle,
maxLines = 1, textOverflow = TextOverflow.Ellipsis,
space = 4.dp
)
}
}
}

@Composable
private fun DetailsForSpliceOut(
payment: SpliceOutgoingPayment
) {
TechnicalRowSelectable(
label = stringResource(id = R.string.paymentdetails_splice_out_channel_label),
value = payment.channelId.toHex()
)
ChannelIdRow(channelId = payment.channelId, label = stringResource(id = R.string.paymentdetails_splice_out_channel_label))
TechnicalRowSelectable(
label = stringResource(id = R.string.paymentdetails_bitcoin_address_label),
value = payment.address
)
TechnicalRow(
label = stringResource(id = R.string.paymentdetails_tx_id_label),
content = { TransactionLinkButton(txId = payment.txId) }
)

TransactionRow(payment.txId)
}

@Composable
@@ -425,7 +441,7 @@ private fun DetailsForIncoming(
Row {
Text(text = stringResource(id = R.string.paymentdetails_dualswapin_tx_value, index + 1))
Spacer(modifier = Modifier.width(4.dp))
TransactionLinkButton(txId = outpoint.txid)
InlineTransactionLink(txId = outpoint.txid)
}
}
}
@@ -445,11 +461,12 @@ private fun ReceivedWithLightning(
Text(text = stringResource(id = R.string.paymentdetails_received_with_lightning))
}
if (receivedWith.channelId != ByteVector32.Zeroes) {
TechnicalRow(label = stringResource(id = R.string.paymentdetails_channel_id_label)) {
Text(text = receivedWith.channelId.toHex())
}
ChannelIdRow(receivedWith.channelId)
}
receivedWith.fundingFee?.let {
TransactionRow(it.fundingTxId)
}
TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen)
TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen)
}

@Composable
@@ -462,15 +479,10 @@ private fun ReceivedWithNewChannel(
}
val channelId = receivedWith.channelId
if (channelId != ByteVector32.Zeroes) { // backward compat
TechnicalRow(label = stringResource(id = R.string.paymentdetails_channel_id_label)) {
Text(text = channelId.toHex())
}
ChannelIdRow(channelId)
}
TechnicalRow(
label = stringResource(id = R.string.paymentdetails_tx_id_label),
content = { TransactionLinkButton(txId = receivedWith.txId) }
)
TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen)
TransactionRow(receivedWith.txId)
TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen)
}

@Composable
@@ -483,15 +495,21 @@ private fun ReceivedWithSpliceIn(
}
val channelId = receivedWith.channelId
if (channelId != ByteVector32.Zeroes) { // backward compat
TechnicalRow(label = stringResource(id = R.string.paymentdetails_channel_id_label)) {
Text(text = channelId.toHex())
}
ChannelIdRow(channelId)
}
TechnicalRow(
label = stringResource(id = R.string.paymentdetails_tx_id_label),
content = { TransactionLinkButton(txId = receivedWith.txId) }
)
TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amount, rateThen = rateThen)
TransactionRow(receivedWith.txId)
TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_received_label), amount = receivedWith.amountReceived, rateThen = rateThen)
}

@Composable
private fun ReceivedWithFeeCredit(
receivedWith: IncomingPayment.ReceivedWith.AddedToFeeCredit,
rateThen: ExchangeRate.BitcoinPriceRate?
) {
TechnicalRow(label = stringResource(id = R.string.paymentdetails_received_with_label)) {
Text(text = stringResource(id = R.string.paymentdetails_received_with_fee_credit))
}
TechnicalRowAmount(label = stringResource(id = R.string.paymentdetails_amount_added_to_fee_credit_label), amount = receivedWith.amountReceived, rateThen = rateThen)
}

@Composable
@@ -684,3 +702,53 @@ private fun TechnicalRowWithCopy(label: String, value: String) {
}
}
}

@Composable
private fun TechnicalRowClickable(
label: String,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
TechnicalRow(label = label) {
Clickable(
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier
.fillMaxWidth()
.offset(x = (-8).dp),
shape = RoundedCornerShape(12.dp),
backgroundColor = mutedBgColor,
) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) {
content()
}
}
}
}

@Composable
private fun TransactionRow(txId: TxId) {
val context = LocalContext.current
val link = txUrl(txId = txId)
TechnicalRowClickable(
label = stringResource(id = R.string.paymentdetails_tx_id_label),
onClick = { openLink(context, link) },
onLongClick = { copyToClipboard(context, txId.toString()) }
) {
TextWithIcon(text = txId.toString(), icon = R.drawable.ic_external_link, maxLines = 1, textOverflow = TextOverflow.Ellipsis, space = 4.dp)
}
}

@Composable
private fun ChannelIdRow(channelId: ByteVector32, label: String = stringResource(id = R.string.paymentdetails_channel_id_label)) {
val context = LocalContext.current
val navController = navController
TechnicalRowClickable(
label = label,
onClick = { navController.navigate("${Screen.ChannelDetails.route}?id=${channelId.toHex()}") },
onLongClick = { copyToClipboard(context, channelId.toHex()) }
) {
TextWithIcon(text = channelId.toHex(), icon = R.drawable.ic_zap, maxLines = 1, textOverflow = TextOverflow.Ellipsis, space = 4.dp)
}
}
Original file line number Diff line number Diff line change
@@ -36,6 +36,7 @@ import fr.acinq.phoenix.android.components.Card
import fr.acinq.phoenix.android.components.DefaultScreenHeader
import fr.acinq.phoenix.android.components.DefaultScreenLayout
import fr.acinq.phoenix.android.components.feedback.ErrorMessage
import fr.acinq.phoenix.android.payments.details.splash.PaymentDetailsSplashView
import fr.acinq.phoenix.data.WalletPaymentFetchOptions
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentInfo
Original file line number Diff line number Diff line change
@@ -34,19 +34,18 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.db.OutgoingPayment
import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment
@@ -68,6 +67,7 @@ import fr.acinq.phoenix.data.WalletPaymentInfo
import fr.acinq.phoenix.data.walletPaymentId
import fr.acinq.phoenix.utils.extensions.WalletPaymentState
import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata
import fr.acinq.phoenix.utils.extensions.isPaidInTheFuture
import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest
import fr.acinq.phoenix.utils.extensions.state

@@ -142,7 +142,8 @@ fun PaymentLine(
Row {
PaymentDescription(paymentInfo = paymentInfo, contactInfo = contactInfo, modifier = Modifier.weight(1.0f))
Spacer(modifier = Modifier.width(16.dp))
if (payment.state() != WalletPaymentState.Failure) {
val hideAmount = payment.state() == WalletPaymentState.Failure || (payment is InboundLiquidityOutgoingPayment && payment.isPaidInTheFuture())
if (!hideAmount) {
val isOutgoing = payment is OutgoingPayment
if (isAmountRedacted) {
Text(text = "****")
@@ -176,12 +177,11 @@ private fun PaymentDescription(
contactInfo: ContactInfo?,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val payment = paymentInfo.payment
val metadata = paymentInfo.metadata
val peer by business.peerManager.peerState.collectAsState()

val desc = when (paymentInfo.isLegacyMigration(peer)) {
val desc = when (payment.isLegacyMigration(metadata, peer)) {
null -> stringResource(id = R.string.paymentdetails_desc_closing_channel) // not sure yet, but we still know it's a closing
true -> stringResource(id = R.string.paymentdetails_desc_legacy_migration)
false -> metadata.userDescription
@@ -190,7 +190,7 @@ private fun PaymentDescription(
if (contactInfo != null) offerMetadata.payerNote else null
}
?: payment.outgoingInvoiceRequest()?.payerNote
?: payment.smartDescription(context)
?: payment.smartDescription()
}

Text(

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/*
* 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.payments.details.splash

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.db.OutgoingPayment
import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment
import fr.acinq.lightning.db.SpliceOutgoingPayment
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.components.AmountView
import fr.acinq.phoenix.android.components.BorderButton
import fr.acinq.phoenix.android.components.Button
import fr.acinq.phoenix.android.components.DefaultScreenHeader
import fr.acinq.phoenix.android.components.PrimarySeparator
import fr.acinq.phoenix.android.components.SplashClickableContent
import fr.acinq.phoenix.android.components.SplashLabelRow
import fr.acinq.phoenix.android.components.SplashLayout
import fr.acinq.phoenix.android.components.TextInput
import fr.acinq.phoenix.android.components.TextWithIcon
import fr.acinq.phoenix.android.utils.borderColor
import fr.acinq.phoenix.android.utils.mutedBgColor
import fr.acinq.phoenix.android.utils.negativeColor
import fr.acinq.phoenix.android.utils.positiveColor
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentInfo
import fr.acinq.phoenix.utils.extensions.WalletPaymentState
import fr.acinq.phoenix.utils.extensions.state

@Composable
fun PaymentDetailsSplashView(
onBackClick: () -> Unit,
data: WalletPaymentInfo,
onDetailsClick: (WalletPaymentId) -> Unit,
onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit,
fromEvent: Boolean,
) {
val payment = data.payment
SplashLayout(
header = { DefaultScreenHeader(onBackClick = onBackClick) },
topContent = { PaymentStatus(data.payment, fromEvent, onCpfpSuccess = onBackClick) }
) {
if (payment is InboundLiquidityOutgoingPayment && payment.purchase.paymentDetails is LiquidityAds.PaymentDetails.FromFutureHtlc) {
Unit
} else {
AmountView(
amount = when (payment) {
is InboundLiquidityOutgoingPayment -> payment.amount
is OutgoingPayment -> payment.amount - payment.fees
is IncomingPayment -> payment.amount
},
amountTextStyle = MaterialTheme.typography.body1.copy(fontSize = 30.sp),
separatorSpace = 4.dp,
prefix = stringResource(id = if (payment is OutgoingPayment) R.string.paymentline_prefix_sent else R.string.paymentline_prefix_received)
)
Spacer(modifier = Modifier.height(36.dp))
PrimarySeparator(
height = 6.dp,
color = when (payment.state()) {
WalletPaymentState.Failure -> negativeColor
WalletPaymentState.SuccessOffChain, WalletPaymentState.SuccessOnChain -> positiveColor
else -> mutedBgColor
}
)
}
Spacer(modifier = Modifier.height(36.dp))

when (payment) {
is IncomingPayment -> SplashIncoming(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate)
is LightningOutgoingPayment -> SplashLightningOutgoing(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate)
is ChannelCloseOutgoingPayment -> SplashChannelClose(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate)
is SpliceCpfpOutgoingPayment -> SplashSpliceOutCpfp(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate)
is SpliceOutgoingPayment -> SplashSpliceOut(payment = payment, metadata = data.metadata, onMetadataDescriptionUpdate = onMetadataDescriptionUpdate)
is InboundLiquidityOutgoingPayment -> SplashLiquidityPurchase(payment = payment)
}

Spacer(modifier = Modifier.height(48.dp))
BorderButton(
text = stringResource(id = R.string.paymentdetails_details_button),
borderColor = borderColor,
textStyle = MaterialTheme.typography.caption,
icon = R.drawable.ic_tool,
iconTint = MaterialTheme.typography.caption.color,
onClick = { onDetailsClick(data.id()) },
)
}
}

@Composable
fun SplashDescription(
description: String?,
userDescription: String?,
paymentId: WalletPaymentId,
onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit,
) {
var showEditDescriptionDialog by remember { mutableStateOf(false) }

Spacer(modifier = Modifier.height(8.dp))
if (!(description.isNullOrBlank() && !userDescription.isNullOrBlank())) {
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_desc_label)) {
if (description.isNullOrBlank()) {
Text(
text = stringResource(id = R.string.paymentdetails_no_description),
style = MaterialTheme.typography.caption.copy(fontStyle = FontStyle.Italic)
)
} else {
Text(text = description)
}
}
}
SplashLabelRow(label = if (userDescription.isNullOrBlank()) "" else stringResource(id = R.string.paymentdetails_note_label)) {
SplashClickableContent(onClick = { showEditDescriptionDialog = true }) {
if (!userDescription.isNullOrBlank()) {
Text(text = userDescription)
Spacer(modifier = Modifier.height(8.dp))
}
TextWithIcon(
text = stringResource(
id = when (userDescription) {
null -> R.string.paymentdetails_attach_desc_button
else -> R.string.paymentdetails_edit_desc_button
}
),
textStyle = MaterialTheme.typography.subtitle2,
icon = R.drawable.ic_edit,
iconTint = MaterialTheme.typography.subtitle2.color,
space = 6.dp,
)
}
}

if (showEditDescriptionDialog) {
CustomNoteDialog(
initialDescription = userDescription,
onConfirm = {
onMetadataDescriptionUpdate(paymentId, it?.trim()?.takeIf { it.isNotBlank() })
showEditDescriptionDialog = false
},
onDismiss = { showEditDescriptionDialog = false }
)
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CustomNoteDialog(
initialDescription: String?,
onConfirm: (String?) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var description by rememberSaveable { mutableStateOf(initialDescription) }

ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
containerColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.onSurface,
scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.1f),
) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(top = 0.dp, start = 24.dp, end = 24.dp, bottom = 70.dp),
) {
Text(text = stringResource(id = R.string.paymentdetails_edit_dialog_title), style = MaterialTheme.typography.body2)
Spacer(modifier = Modifier.height(16.dp))
TextInput(
modifier = Modifier.fillMaxWidth(),
text = description ?: "",
onTextChange = { description = it.takeIf { it.isNotBlank() } },
minLines = 2,
maxLines = 6,
maxChars = 280,
staticLabel = stringResource(id = R.string.paymentdetails_edit_dialog_input_label)
)
Spacer(modifier = Modifier.height(24.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(onClick = onDismiss, text = stringResource(id = R.string.btn_cancel), shape = CircleShape)
Button(
onClick = { onConfirm(description) },
text = stringResource(id = R.string.btn_save),
icon = R.drawable.ic_check,
enabled = description != initialDescription,
space = 8.dp,
shape = CircleShape
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* 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.payments.details.splash

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import fr.acinq.lightning.db.ChannelCloseOutgoingPayment
import fr.acinq.lightning.utils.msat
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.SplashLabelRow
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.MSatDisplayPolicy
import fr.acinq.phoenix.android.utils.isLegacyMigration
import fr.acinq.phoenix.android.utils.smartDescription
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentMetadata
import fr.acinq.phoenix.data.walletPaymentId

@Composable
fun SplashChannelClose(
payment: ChannelCloseOutgoingPayment,
metadata: WalletPaymentMetadata,
onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit,
) {
val peer by business.peerManager.peerState.collectAsState()

val isLegacyMigration = payment.isLegacyMigration(metadata, peer)
val description = when (isLegacyMigration) {
null -> stringResource(id = R.string.paymentdetails_desc_closing_channel) // not sure yet, but we still know it's a closing
true -> stringResource(id = R.string.paymentdetails_desc_legacy_migration)
false -> payment.smartDescription()
}

SplashDescription(
description = description,
userDescription = metadata.userDescription,
paymentId = payment.walletPaymentId(),
onMetadataDescriptionUpdate = onMetadataDescriptionUpdate
)
SplashDestination(payment, metadata)
SplashFee(payment = payment)
}

@Composable
private fun SplashDestination(payment: ChannelCloseOutgoingPayment, metadata: WalletPaymentMetadata) {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) {
SelectionContainer {
Text(text = payment.address)
}
}
}

@Composable
private fun SplashFee(payment: ChannelCloseOutgoingPayment) {
val btcUnit = LocalBitcoinUnit.current
if (payment.fees > 0.msat) {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) {
Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* 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.payments.details.splash

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import fr.acinq.bitcoin.PublicKey
import fr.acinq.lightning.db.IncomingPayment
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.sum
import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.SplashLabelRow
import fr.acinq.phoenix.android.components.contact.ContactCompactView
import fr.acinq.phoenix.android.components.contact.OfferContactState
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.MSatDisplayPolicy
import fr.acinq.phoenix.android.utils.smartDescription
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentMetadata
import fr.acinq.phoenix.data.walletPaymentId
import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata

@Composable
fun SplashIncoming(
payment: IncomingPayment,
metadata: WalletPaymentMetadata,
onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit,
) {
payment.incomingOfferMetadata()?.let { meta ->
meta.payerNote?.takeIf { it.isNotBlank() }?.let {
OfferPayerNote(payerNote = it)
}
OfferSentBy(payerPubkey = meta.payerKey, !meta.payerNote.isNullOrBlank())
}

SplashDescription(
description = payment.smartDescription(),
userDescription = metadata.userDescription,
paymentId = payment.walletPaymentId(),
onMetadataDescriptionUpdate = onMetadataDescriptionUpdate,
)
SplashFee(payment)
}

@Composable
fun OfferPayerNote(payerNote: String) {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_note_label)) {
Text(text = payerNote)
}
}

@Composable
private fun OfferSentBy(payerPubkey: PublicKey?, hasPayerNote: Boolean) {
val contactsManager = business.contactsManager
val contactState = remember { mutableStateOf<OfferContactState>(OfferContactState.Init) }
LaunchedEffect(Unit) {
contactState.value = payerPubkey?.let {
contactsManager.getContactForPayerPubkey(it)
}?.let { OfferContactState.Found(it) } ?: OfferContactState.NotFound
}

SplashLabelRow(label = stringResource(id = R.string.paymentdetails_offer_sender_label)) {
when (val res = contactState.value) {
is OfferContactState.Init -> Text(text = stringResource(id = R.string.utils_loading_data))
is OfferContactState.NotFound -> {
Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown))
if (hasPayerNote) {
Spacer(modifier = Modifier.height(4.dp))
Text(text = stringResource(id = R.string.paymentdetails_offer_sender_unknown_details), style = MaterialTheme.typography.subtitle2)
}
}
is OfferContactState.Found -> {
ContactCompactView(
contact = res.contact,
currentOffer = null,
onContactChange = { contactState.value = if (it == null) OfferContactState.NotFound else OfferContactState.Found(it) },
)
}
}
}
}

@Composable
private fun SplashFee(
payment: IncomingPayment
) {
val btcUnit = LocalBitcoinUnit.current
val receivedWithOnChain = remember(payment) { payment.received?.receivedWith?.filterIsInstance<IncomingPayment.ReceivedWith.OnChainIncomingPayment>() ?: emptyList() }
val receivedWithLightning = remember(payment) { payment.received?.receivedWith?.filterIsInstance<IncomingPayment.ReceivedWith.LightningPayment>() ?: emptyList() }

if (receivedWithOnChain.isNotEmpty() || receivedWithLightning.isNotEmpty()) {

val paymentsManager = business.paymentsManager
val txIds = remember(receivedWithLightning) { receivedWithLightning.mapNotNull { it.fundingFee?.fundingTxId } }
val relatedLiquidityPayments by produceState(initialValue = emptyList()) {
value = txIds.mapNotNull { paymentsManager.getLiquidityPurchaseForTxId(it) }
}

val serviceFee = remember(receivedWithOnChain, relatedLiquidityPayments) {
receivedWithOnChain.map { it.serviceFee }.sum() + relatedLiquidityPayments.map { it.feePaidFromFutureHtlc.serviceFee.toMilliSatoshi() }.sum()
}
val miningFee = remember(receivedWithOnChain, relatedLiquidityPayments) {
receivedWithOnChain.map { it.miningFee }.sum() + relatedLiquidityPayments.map { it.feePaidFromFutureHtlc.miningFee }.sum()
}

Spacer(modifier = Modifier.height(8.dp))
if (serviceFee > 0.msat) {
SplashLabelRow(
label = stringResource(id = R.string.paymentdetails_service_fees_label),
helpMessage = stringResource(R.string.paymentdetails_service_fees_desc)
) {
Text(text = serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW))
}
Spacer(modifier = Modifier.height(8.dp))
}

if (miningFee > 0.sat) {
SplashLabelRow(
label = stringResource(id = R.string.paymentdetails_funding_fees_label),
helpMessage = stringResource(R.string.paymentdetails_funding_fees_desc)
) {
Text(text = miningFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.HIDE))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
/*
* 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.payments.details.splash

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import fr.acinq.bitcoin.utils.Either
import fr.acinq.lightning.db.LightningOutgoingPayment
import fr.acinq.lightning.payment.FinalFailure
import fr.acinq.lightning.payment.OutgoingPaymentFailure
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.components.ProgressView
import fr.acinq.phoenix.android.components.SplashLabelRow
import fr.acinq.phoenix.android.components.WebLink
import fr.acinq.phoenix.android.components.contact.ContactOrOfferView
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.MSatDisplayPolicy
import fr.acinq.phoenix.android.utils.smartDescription
import fr.acinq.phoenix.data.LnurlPayMetadata
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentMetadata
import fr.acinq.phoenix.data.lnurl.LnurlPay
import fr.acinq.phoenix.data.walletPaymentId
import fr.acinq.phoenix.utils.extensions.WalletPaymentState
import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest
import fr.acinq.phoenix.utils.extensions.state
import io.ktor.http.Url
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec

@Composable
fun SplashLightningOutgoing(
payment: LightningOutgoingPayment,
metadata: WalletPaymentMetadata,
onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit,
) {
metadata.lnurl?.let { lnurlMeta ->
LnurlPayInfoView(payment, lnurlMeta)
}

payment.outgoingInvoiceRequest()?.payerNote?.takeIf { it.isNotBlank() }?.let {
OfferPayerNote(payerNote = it)
}

SplashDescription(
description = payment.smartDescription(),
userDescription = metadata.userDescription,
paymentId = payment.walletPaymentId(),
onMetadataDescriptionUpdate = onMetadataDescriptionUpdate
)
SplashDestination(payment, metadata)
SplashFee(payment = payment)

(payment.status as? LightningOutgoingPayment.Status.Completed.Failed)?.let { status ->
PaymentErrorView(status = status, failedParts = payment.parts.map { it.status }.filterIsInstance<LightningOutgoingPayment.Part.Status.Failed>())
}
}

@Composable
private fun SplashDestination(payment: LightningOutgoingPayment, metadata: WalletPaymentMetadata) {
val lnId = metadata.lnurl?.pay?.metadata?.lnid?.takeIf { it.isNotBlank() }
if (lnId != null) {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_zap) {
SelectionContainer {
Text(text = lnId)
}
}
}
val details = payment.details
if (details is LightningOutgoingPayment.Details.Blinded) {
val offer = details.paymentRequest.invoiceRequest.offer
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label)) {
ContactOrOfferView(offer = offer)
}
}
}

@Composable
private fun SplashFee(payment: LightningOutgoingPayment) {
val btcUnit = LocalBitcoinUnit.current
if (payment.state() == WalletPaymentState.SuccessOffChain) {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) {
Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS))
}
}
}

@Composable
private fun LnurlPayInfoView(payment: LightningOutgoingPayment, metadata: LnurlPayMetadata) {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_service)) {
SelectionContainer {
Text(text = metadata.pay.callback.host)
}
}
metadata.successAction?.let {
LnurlSuccessAction(payment = payment, action = it)
}
}

@Composable
private fun LnurlSuccessAction(payment: LightningOutgoingPayment, action: LnurlPay.Invoice.SuccessAction) {
Spacer(modifier = Modifier.height(8.dp))
when (action) {
is LnurlPay.Invoice.SuccessAction.Message -> {
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_message_label)) {
SelectionContainer {
Text(text = action.message)
}
}
}
is LnurlPay.Invoice.SuccessAction.Url -> {
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_label)) {
Text(text = action.description)
WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = action.url.toString())
}
}
is LnurlPay.Invoice.SuccessAction.Aes -> {
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_label)) {
val status = payment.status
if (status is LightningOutgoingPayment.Status.Completed.Succeeded.OffChain) {
val deciphered by produceState<String?>(initialValue = null) {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(status.preimage.toByteArray(), "AES"), IvParameterSpec(action.iv.toByteArray()))
value = String(cipher.doFinal(action.ciphertext.toByteArray()), Charsets.UTF_8)
}
Text(text = action.description)
when (deciphered) {
null -> ProgressView(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting), padding = PaddingValues(0.dp))
else -> {
val url = try {
Url(deciphered!!)
} catch (e: Exception) {
null
}
if (url != null) {
WebLink(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_url_button), url = url.toString())
} else {
SelectionContainer {
Text(text = deciphered!!)
}
}
}
}
} else {
Text(text = stringResource(id = R.string.paymentdetails_lnurlpay_action_aes_decrypting))
}
}
}
}
}

@Composable
private fun PaymentErrorView(status: LightningOutgoingPayment.Status.Completed.Failed, failedParts: List<LightningOutgoingPayment.Part.Status.Failed>) {
val failure = remember(status, failedParts) { OutgoingPaymentFailure(status.reason, failedParts) }
translatePaymentError(failure).let {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_error_label)) {
Text(text = it)
}
}
}

@Composable
fun translatePaymentError(paymentFailure: OutgoingPaymentFailure): String {
val context = LocalContext.current
val errorMessage = remember(key1 = paymentFailure) {
when (val result = paymentFailure.explain()) {
is Either.Left -> {
when (val partFailure = result.value) {
is LightningOutgoingPayment.Part.Status.Failure.Uninterpretable -> partFailure.message
LightningOutgoingPayment.Part.Status.Failure.ChannelIsClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing)
LightningOutgoingPayment.Part.Status.Failure.ChannelIsSplicing -> context.getString(R.string.outgoing_failuremessage_channel_splicing)
LightningOutgoingPayment.Part.Status.Failure.NotEnoughFees -> context.getString(R.string.outgoing_failuremessage_not_enough_fee)
LightningOutgoingPayment.Part.Status.Failure.NotEnoughFunds -> context.getString(R.string.outgoing_failuremessage_not_enough_balance)
LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooBig -> context.getString(R.string.outgoing_failuremessage_too_big)
LightningOutgoingPayment.Part.Status.Failure.PaymentAmountTooSmall -> context.getString(R.string.outgoing_failuremessage_too_small)
LightningOutgoingPayment.Part.Status.Failure.PaymentExpiryTooBig -> context.getString(R.string.outgoing_failuremessage_expiry_too_big)
LightningOutgoingPayment.Part.Status.Failure.RecipientRejectedPayment -> context.getString(R.string.outgoing_failuremessage_rejected_by_recipient)
LightningOutgoingPayment.Part.Status.Failure.RecipientIsOffline -> context.getString(R.string.outgoing_failuremessage_recipient_offline)
LightningOutgoingPayment.Part.Status.Failure.RecipientLiquidityIssue -> context.getString(R.string.outgoing_failuremessage_not_enough_liquidity)
LightningOutgoingPayment.Part.Status.Failure.TemporaryRemoteFailure -> context.getString(R.string.outgoing_failuremessage_temporary_failure)
LightningOutgoingPayment.Part.Status.Failure.TooManyPendingPayments -> context.getString(R.string.outgoing_failuremessage_too_many_pending)
}
}
is Either.Right -> {
when (result.value) {
FinalFailure.InvalidPaymentId -> context.getString(R.string.outgoing_failuremessage_invalid_id)
FinalFailure.AlreadyPaid -> context.getString(R.string.outgoing_failuremessage_alreadypaid)
FinalFailure.ChannelClosing -> context.getString(R.string.outgoing_failuremessage_channel_closing)
FinalFailure.ChannelNotConnected -> context.getString(R.string.outgoing_failuremessage_not_connected)
FinalFailure.ChannelOpening -> context.getString(R.string.outgoing_failuremessage_channel_opening)
FinalFailure.FeaturesNotSupported -> context.getString(R.string.outgoing_failuremessage_unsupported_features)
FinalFailure.InsufficientBalance -> context.getString(R.string.outgoing_failuremessage_not_enough_balance)
FinalFailure.InvalidPaymentAmount -> context.getString(R.string.outgoing_failuremessage_invalid_amount)
FinalFailure.NoAvailableChannels -> context.getString(R.string.outgoing_failuremessage_no_available_channels)
FinalFailure.RecipientUnreachable -> context.getString(R.string.outgoing_failuremessage_noroutefound)
FinalFailure.RetryExhausted -> context.getString(R.string.outgoing_failuremessage_noroutefound)
FinalFailure.UnknownError -> context.getString(R.string.outgoing_failuremessage_unknown)
FinalFailure.WalletRestarted -> context.getString(R.string.outgoing_failuremessage_restarted)
}
}
}
}
return errorMessage
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* 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.payments.details.splash

import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.Screen
import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.BorderButton
import fr.acinq.phoenix.android.components.BottomSheetDialog
import fr.acinq.phoenix.android.components.Button
import fr.acinq.phoenix.android.components.Clickable
import fr.acinq.phoenix.android.components.SplashClickableContent
import fr.acinq.phoenix.android.components.SplashLabelRow
import fr.acinq.phoenix.android.components.TextWithIcon
import fr.acinq.phoenix.android.navController
import fr.acinq.phoenix.android.navigateToPaymentDetails
import fr.acinq.phoenix.android.payments.details.PaymentLine
import fr.acinq.phoenix.android.payments.details.PaymentLineLoading
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.MSatDisplayPolicy
import fr.acinq.phoenix.android.utils.mutedBgColor
import fr.acinq.phoenix.data.WalletPaymentFetchOptions
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentInfo
import fr.acinq.phoenix.utils.extensions.isManualPurchase
import fr.acinq.phoenix.utils.extensions.isPaidInTheFuture
import fr.acinq.phoenix.utils.extensions.relatedPaymentIds
import kotlinx.coroutines.launch

@Composable
fun SplashLiquidityPurchase(
payment: InboundLiquidityOutgoingPayment,
) {
SplashPurchase(payment = payment)
SplashFee(payment = payment)
SplashRelatedPayments(payment)
}

@Composable
private fun SplashPurchase(
payment: InboundLiquidityOutgoingPayment,
) {
val btcUnit = LocalBitcoinUnit.current
SplashLabelRow(label = "Liquidity") {
Text(text = payment.purchase.amount.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS))
}
}

@Composable
private fun SplashFee(
payment: InboundLiquidityOutgoingPayment
) {
val btcUnit = LocalBitcoinUnit.current
if (!payment.isPaidInTheFuture()) {
Spacer(modifier = Modifier.height(8.dp))
val miningFee = payment.feePaidFromChannelBalance.miningFee
val serviceFee = payment.feePaidFromChannelBalance.serviceFee
SplashLabelRow(
label = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_label),
helpMessage = stringResource(id = R.string.paymentdetails_liquidity_miner_fee_help)
) {
Text(text = miningFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS))
}
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(
label = stringResource(id = R.string.paymentdetails_liquidity_service_fee_label),
helpMessage = stringResource(id = R.string.paymentdetails_liquidity_service_fee_help)
) {
Text(text = serviceFee.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS))
}
}
}

@Composable
private fun SplashRelatedPayments(payment: InboundLiquidityOutgoingPayment) {
val relatedPaymentIds = payment.relatedPaymentIds()
if (relatedPaymentIds.isNotEmpty()) {
val navController = navController
val paymentId = relatedPaymentIds.first()
Spacer(modifier = Modifier.height(4.dp))
SplashLabelRow(
label = stringResource(id = R.string.paymentdetails_liquidity_caused_by_label),
helpMessage = if (payment.isManualPurchase()) null else stringResource(id = R.string.paymentdetails_liquidity_caused_by_help),
helpLink = stringResource(id = R.string.paymentdetails_liquidity_caused_by_help_link) to "https://acinq.co/faq",
) {
Button(
text = paymentId.dbId,
icon = R.drawable.ic_zap,
onClick = { navigateToPaymentDetails(navController, paymentId, isFromEvent = false) },
maxLines = 1,
padding = PaddingValues(horizontal = 7.dp, vertical = 5.dp),
space = 4.dp,
shape = RoundedCornerShape(12.dp),
backgroundColor = mutedBgColor,
modifier = Modifier.widthIn(max = 130.dp)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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.payments.details.splash

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import fr.acinq.lightning.db.SpliceOutgoingPayment
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.components.SplashLabelRow
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.MSatDisplayPolicy
import fr.acinq.phoenix.android.utils.smartDescription
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentMetadata
import fr.acinq.phoenix.data.walletPaymentId

@Composable
fun SplashSpliceOut(
payment: SpliceOutgoingPayment,
metadata: WalletPaymentMetadata,
onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit,
) {
SplashDescription(
description = payment.smartDescription(),
userDescription = metadata.userDescription,
paymentId = payment.walletPaymentId(),
onMetadataDescriptionUpdate = onMetadataDescriptionUpdate
)
SplashDestination(payment)
SplashFee(payment = payment)
}

@Composable
private fun SplashDestination(payment: SpliceOutgoingPayment) {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) {
SelectionContainer {
Text(text = payment.address)
}
}
}

@Composable
private fun SplashFee(payment: SpliceOutgoingPayment) {
val btcUnit = LocalBitcoinUnit.current
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) {
Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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.payments.details.splash

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment
import fr.acinq.phoenix.android.LocalBitcoinUnit
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.components.SplashLabelRow
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.MSatDisplayPolicy
import fr.acinq.phoenix.android.utils.smartDescription
import fr.acinq.phoenix.data.WalletPaymentId
import fr.acinq.phoenix.data.WalletPaymentMetadata
import fr.acinq.phoenix.data.walletPaymentId

@Composable
fun SplashSpliceOutCpfp(
payment: SpliceCpfpOutgoingPayment,
metadata: WalletPaymentMetadata,
onMetadataDescriptionUpdate: (WalletPaymentId, String?) -> Unit,
) {
SplashDescription(
description = payment.smartDescription(),
userDescription = metadata.userDescription,
paymentId = payment.walletPaymentId(),
onMetadataDescriptionUpdate = onMetadataDescriptionUpdate
)
SplashDestination()
SplashFee(payment = payment)
}

@Composable
private fun SplashDestination() {
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_destination_label), icon = R.drawable.ic_chain) {
SelectionContainer {
Text(text = stringResource(id = R.string.paymentdetails_destination_cpfp_value))
}
}
}

@Composable
private fun SplashFee(payment: SpliceCpfpOutgoingPayment) {
val btcUnit = LocalBitcoinUnit.current
Spacer(modifier = Modifier.height(8.dp))
SplashLabelRow(label = stringResource(id = R.string.paymentdetails_fees_label)) {
Text(text = payment.fees.toPrettyString(btcUnit, withUnit = true, mSatDisplayPolicy = MSatDisplayPolicy.SHOW_IF_ZERO_SATS))
}
}
Original file line number Diff line number Diff line change
@@ -29,9 +29,8 @@ import androidx.lifecycle.viewModelScope
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.android.BuildConfig
import fr.acinq.phoenix.android.utils.Converter.toAbsoluteDateTimeString
import fr.acinq.phoenix.android.utils.smartDescription
import fr.acinq.phoenix.android.utils.basicDescription
import fr.acinq.phoenix.data.WalletPaymentFetchOptions
import fr.acinq.phoenix.db.SqlitePaymentsDb
import fr.acinq.phoenix.managers.DatabaseManager
import fr.acinq.phoenix.managers.PaymentsFetcher
import fr.acinq.phoenix.managers.PeerManager
@@ -109,7 +108,7 @@ class CsvExportViewModel(
).map { paymentRow ->
paymentsFetcher.getPayment(paymentRow, WalletPaymentFetchOptions.All)?.let { info ->
val descriptions = listOf(
info.payment.smartDescription(context),
info.payment.basicDescription(),
info.metadata.userDescription,
info.metadata.lnurl?.pay?.metadata?.longDesc
).mapNotNull { it.takeIf { !it.isNullOrBlank() } }
Original file line number Diff line number Diff line change
@@ -147,15 +147,15 @@ fun PaymentsHistoryView(
.distinctUntilChanged()
.filter { index ->
val entriesInListCount = groupedPayments.entries.size + payments.size
val isLastElementFetched = index == entriesInListCount - 1
val isLastElementFetched = index >= entriesInListCount - 1
isLastElementFetched
}
.distinctUntilChanged()
.collect { index ->
val hasMorePaymentsToFetch = payments.size < allPaymentsCount
if (hasMorePaymentsToFetch) {
// Subscribe to a bit more payments. Ideally would be the screen height / height of each payment.
paymentsViewModel.subscribeToPayments(offset = 0, count = index + 10)
paymentsViewModel.subscribeToPayments(offset = 0, count = index + 14)
}
}
}
Original file line number Diff line number Diff line change
@@ -53,6 +53,7 @@ import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.channel.ChannelFundingResponse
import fr.acinq.lightning.channel.ChannelManagementFees
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.sum
@@ -237,22 +238,28 @@ private fun RequestLiquidityBottomSection(
ErrorMessage(header = stringResource(id = R.string.validation_invalid_amount))
} else {
ReviewLiquidityRequest(
onConfirm = { vm.requestInboundLiquidity(amount = state.amount, feerate = state.actualFeerate) }
onConfirm = { vm.requestInboundLiquidity(amount = state.amount, feerate = state.actualFeerate, fundingRate = state.fundingRate) }
)
}
}
is RequestLiquidityState.Requesting -> {
ProgressView(text = stringResource(id = R.string.liquidityads_requesting_spinner))
}
is RequestLiquidityState.Complete.Success -> {
LeaseSuccessDetails(liquidityDetails = state.response)
LiquiditySuccessDetails(liquidityDetails = state.response)
}
is RequestLiquidityState.Error.NoChannelsAvailable -> {
ErrorMessage(
header = stringResource(id = R.string.liquidityads_error_header),
details = stringResource(id = R.string.liquidityads_error_channels_unavailable)
)
}
is RequestLiquidityState.Error.InvalidFundingAmount -> {
ErrorMessage(
header = stringResource(id = R.string.liquidityads_error_header),
details = stringResource(id = R.string.liquidityads_error_invalid_funding_amount)
)
}
is RequestLiquidityState.Error.Thrown -> {
ErrorMessage(
header = stringResource(id = R.string.liquidityads_error_header),
@@ -375,10 +382,10 @@ private fun ReviewLiquidityRequest(
}

@Composable
private fun LeaseSuccessDetails(liquidityDetails: ChannelCommand.Commitment.Splice.Response.Created) {
private fun LiquiditySuccessDetails(liquidityDetails: ChannelFundingResponse.Success) {
SuccessMessage(
header = stringResource(id = R.string.liquidityads_success),
details = liquidityDetails.liquidityLease?.amount?.let {
details = liquidityDetails.liquidityPurchase?.amount?.let {
stringResource(id = R.string.liquidityads_success_amount, it.toPrettyString(unit = LocalBitcoinUnit.current, withUnit = true))
},
alignment = Alignment.CenterHorizontally,
Original file line number Diff line number Diff line change
@@ -23,9 +23,10 @@ import androidx.lifecycle.viewModelScope
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.channel.ChannelFundingResponse
import fr.acinq.lightning.channel.ChannelManagementFees
import fr.acinq.lightning.wire.LiquidityAds
import fr.acinq.phoenix.managers.AppConfigurationManager
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.PeerManager
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
@@ -38,16 +39,17 @@ import org.slf4j.LoggerFactory
sealed class RequestLiquidityState {
object Init: RequestLiquidityState()
object Estimating: RequestLiquidityState()
data class Estimation(val amount: Satoshi, val fees: ChannelManagementFees, val actualFeerate: FeeratePerKw): RequestLiquidityState()
data class Estimation(val amount: Satoshi, val fees: ChannelManagementFees, val actualFeerate: FeeratePerKw, val fundingRate: LiquidityAds.FundingRate): RequestLiquidityState()
object Requesting: RequestLiquidityState()
sealed class Complete: RequestLiquidityState() {
abstract val response: ChannelCommand.Commitment.Splice.Response
data class Success(override val response: ChannelCommand.Commitment.Splice.Response.Created): Complete()
data class Failed(override val response: ChannelCommand.Commitment.Splice.Response.Failure): Complete()
abstract val response: ChannelFundingResponse
data class Success(override val response: ChannelFundingResponse.Success): Complete()
data class Failed(override val response: ChannelFundingResponse.Failure): Complete()
}
sealed class Error: RequestLiquidityState() {
data class Thrown(val cause: Throwable): Error()
object NoChannelsAvailable: Error()
data object NoChannelsAvailable: Error()
data object InvalidFundingAmount: Error()
}
}

@@ -65,23 +67,29 @@ class RequestLiquidityViewModel(val peerManager: PeerManager, val appConfigManag
}) {
val peer = peerManager.getPeer()
val feerate = appConfigManager.mempoolFeerate.filterNotNull().first().hour
val fundingRate = peer.remoteFundingRates.filterNotNull().first().findRate(amount)
if (fundingRate == null) {
state.value = RequestLiquidityState.Error.InvalidFundingAmount
return@launch
}

peer.estimateFeeForInboundLiquidity(
amount = amount,
targetFeerate = FeeratePerKw(feerate),
leaseRate = NodeParamsManager.liquidityLeaseRate(amount),
fundingRate = fundingRate,
).let { response ->
state.value = when (response) {
null -> RequestLiquidityState.Error.NoChannelsAvailable
else -> {
val (actualFeerate, fees) = response
RequestLiquidityState.Estimation(amount, fees, actualFeerate)
RequestLiquidityState.Estimation(amount, fees, actualFeerate, fundingRate)
}
}
}
}
}

fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw) {
fun requestInboundLiquidity(amount: Satoshi, feerate: FeeratePerKw, fundingRate: LiquidityAds.FundingRate) {
if (state.value is RequestLiquidityState.Requesting) return
state.value = RequestLiquidityState.Requesting
viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e ->
@@ -92,12 +100,12 @@ class RequestLiquidityViewModel(val peerManager: PeerManager, val appConfigManag
peer.requestInboundLiquidity(
amount = amount,
feerate = feerate,
leaseRate = NodeParamsManager.liquidityLeaseRate(amount),
fundingRate = fundingRate,
).let { response ->
state.value = when (response) {
null -> RequestLiquidityState.Error.NoChannelsAvailable
is ChannelCommand.Commitment.Splice.Response.Failure -> RequestLiquidityState.Complete.Failed(response)
is ChannelCommand.Commitment.Splice.Response.Created -> RequestLiquidityState.Complete.Success(response)
is ChannelFundingResponse.Failure -> RequestLiquidityState.Complete.Failed(response)
is ChannelFundingResponse.Success -> RequestLiquidityState.Complete.Success(response)
}
}
}
Original file line number Diff line number Diff line change
@@ -42,6 +42,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -64,7 +65,7 @@ import fr.acinq.phoenix.android.components.SplashLayout
import fr.acinq.phoenix.android.components.TextInput
import fr.acinq.phoenix.android.components.contact.ContactOrOfferView
import fr.acinq.phoenix.android.components.feedback.ErrorMessage
import fr.acinq.phoenix.android.payments.details.translatePaymentError
import fr.acinq.phoenix.android.payments.details.splash.translatePaymentError
import fr.acinq.phoenix.android.userPrefs
import fr.acinq.phoenix.android.utils.Converter.toPrettyString

@@ -78,6 +79,7 @@ fun SendOfferView(
val context = LocalContext.current
val balance = business.balanceManager.balance.collectAsState(null).value
val prefBitcoinUnit = LocalBitcoinUnit.current
val keyboardManager = LocalSoftwareKeyboardController.current

val vm = viewModel<SendOfferViewModel>(factory = SendOfferViewModel.Factory(offer, business.peerManager, business.nodeParamsManager, business.contactsManager))
val requestedAmount = offer.amount
@@ -171,7 +173,7 @@ fun SendOfferView(
}

if (showMessageDialog) {
PayerNoteInput(initialMessage = message, onMessageChange = { message = it }, onDismiss = { showMessageDialog = false })
PayerNoteInput(initialMessage = message, onMessageChange = { message = it }, onDismiss = { showMessageDialog = false ; keyboardManager?.hide() })
}
}

Original file line number Diff line number Diff line change
@@ -131,11 +131,9 @@ class ReceiveViewModel(
val startAddress = keyManager.swapInOnChainWallet.getSwapInProtocol(startIndex).address(chain)
val image = BitmapHelper.generateBitmap(startAddress).asImageBitmap()
currentSwapAddress = BitcoinAddressState.Show(startIndex, startAddress, image)
log.info("starting with swap-in address $startAddress:$startIndex")

// monitor the actual address from the swap-in wallet -- might take some time since the wallet must check all previous addresses
peerManager.getPeer().phoenixSwapInWallet.swapInAddressFlow.filterNotNull().collect { (newAddress, newIndex) ->
log.info("swap-in wallet current address update: $newAddress:$newIndex")
val newImage = BitmapHelper.generateBitmap(newAddress).asImageBitmap()
internalDataRepository.saveLastUsedSwapIndex(newIndex)
currentSwapAddress = BitcoinAddressState.Show(newIndex, newAddress, newImage)
Original file line number Diff line number Diff line change
@@ -43,6 +43,7 @@ import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.channel.ChannelFundingResponse
import fr.acinq.lightning.utils.sat
import fr.acinq.lightning.utils.toMilliSatoshi
import fr.acinq.phoenix.android.LocalBitcoinUnit
@@ -417,17 +418,19 @@ private fun ReviewSpliceOutAndConfirm(
}

@Composable
fun spliceFailureDetails(spliceFailure: ChannelCommand.Commitment.Splice.Response.Failure): String = when (spliceFailure) {
is ChannelCommand.Commitment.Splice.Response.Failure.AbortedByPeer -> stringResource(id = R.string.splice_error_aborted_by_peer, spliceFailure.reason)
is ChannelCommand.Commitment.Splice.Response.Failure.CannotCreateCommitTx -> stringResource(id = R.string.splice_error_cannot_create_commit)
is ChannelCommand.Commitment.Splice.Response.Failure.ConcurrentRemoteSplice -> stringResource(id = R.string.splice_error_concurrent_remote)
is ChannelCommand.Commitment.Splice.Response.Failure.ChannelNotQuiescent -> stringResource(id = R.string.splice_error_channel_not_quiescent)
is ChannelCommand.Commitment.Splice.Response.Failure.Disconnected -> stringResource(id = R.string.splice_error_disconnected)
is ChannelCommand.Commitment.Splice.Response.Failure.FundingFailure -> stringResource(id = R.string.splice_error_funding_error, spliceFailure.reason.javaClass.simpleName)
is ChannelCommand.Commitment.Splice.Response.Failure.InsufficientFunds -> stringResource(id = R.string.splice_error_insufficient_funds)
is ChannelCommand.Commitment.Splice.Response.Failure.CannotStartSession -> stringResource(id = R.string.splice_error_cannot_start_session)
is ChannelCommand.Commitment.Splice.Response.Failure.InteractiveTxSessionFailed -> stringResource(id = R.string.splice_error_interactive_session, spliceFailure.reason.javaClass.simpleName)
is ChannelCommand.Commitment.Splice.Response.Failure.InvalidSpliceOutPubKeyScript -> stringResource(id = R.string.splice_error_invalid_pubkey)
is ChannelCommand.Commitment.Splice.Response.Failure.SpliceAlreadyInProgress -> stringResource(id = R.string.splice_error_splice_in_progress)
is ChannelCommand.Commitment.Splice.Response.Failure.InvalidLiquidityAds -> stringResource(id = R.string.splice_error_invalid_liquidity_ads, spliceFailure.reason.details())
fun spliceFailureDetails(spliceFailure: ChannelFundingResponse.Failure): String = when (spliceFailure) {
is ChannelFundingResponse.Failure.AbortedByPeer -> stringResource(id = R.string.splice_error_aborted_by_peer, spliceFailure.reason)
is ChannelFundingResponse.Failure.CannotCreateCommitTx -> stringResource(id = R.string.splice_error_cannot_create_commit)
is ChannelFundingResponse.Failure.ConcurrentRemoteSplice -> stringResource(id = R.string.splice_error_concurrent_remote)
is ChannelFundingResponse.Failure.ChannelNotQuiescent -> stringResource(id = R.string.splice_error_channel_not_quiescent)
is ChannelFundingResponse.Failure.Disconnected -> stringResource(id = R.string.splice_error_disconnected)
is ChannelFundingResponse.Failure.FundingFailure -> stringResource(id = R.string.splice_error_funding_error, spliceFailure.reason.javaClass.simpleName)
is ChannelFundingResponse.Failure.InsufficientFunds -> stringResource(id = R.string.splice_error_insufficient_funds)
is ChannelFundingResponse.Failure.CannotStartSession -> stringResource(id = R.string.splice_error_cannot_start_session)
is ChannelFundingResponse.Failure.InteractiveTxSessionFailed -> stringResource(id = R.string.splice_error_interactive_session, spliceFailure.reason.javaClass.simpleName)
is ChannelFundingResponse.Failure.InvalidSpliceOutPubKeyScript -> stringResource(id = R.string.splice_error_invalid_pubkey)
is ChannelFundingResponse.Failure.SpliceAlreadyInProgress -> stringResource(id = R.string.splice_error_splice_in_progress)
is ChannelFundingResponse.Failure.InvalidLiquidityAds -> stringResource(id = R.string.splice_error_invalid_liquidity_ads, spliceFailure.reason.details())
is ChannelFundingResponse.Failure.InvalidChannelParameters -> stringResource(id = R.string.splice_error_invalid_channel_params, spliceFailure.reason.details())
is ChannelFundingResponse.Failure.UnexpectedMessage -> stringResource(id = R.string.splice_error_unexpected, spliceFailure.msg.type.toString())
}
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@ import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.blockchain.fee.FeeratePerByte
import fr.acinq.lightning.blockchain.fee.FeeratePerKw
import fr.acinq.lightning.channel.ChannelCommand
import fr.acinq.lightning.channel.ChannelFundingResponse
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.phoenix.managers.PeerManager
@@ -45,9 +46,9 @@ sealed class SpliceOutState {
sealed class Complete: SpliceOutState() {
abstract val userAmount: Satoshi
abstract val feerate: FeeratePerKw
abstract val result: ChannelCommand.Commitment.Splice.Response
data class Success(override val userAmount: Satoshi, override val feerate: FeeratePerKw, override val result: ChannelCommand.Commitment.Splice.Response.Created): Complete()
data class Failure(override val userAmount: Satoshi, override val feerate: FeeratePerKw, override val result: ChannelCommand.Commitment.Splice.Response.Failure): Complete()
abstract val result: ChannelFundingResponse
data class Success(override val userAmount: Satoshi, override val feerate: FeeratePerKw, override val result: ChannelFundingResponse.Success): Complete()
data class Failure(override val userAmount: Satoshi, override val feerate: FeeratePerKw, override val result: ChannelFundingResponse.Failure): Complete()
}
sealed class Error: SpliceOutState() {
data class Thrown(val e: Throwable): Error()
@@ -70,7 +71,7 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain
state = SpliceOutState.Error.Thrown(e)
}) {
state = SpliceOutState.Preparing(userAmount = amount, feeratePerByte = feeratePerByte)
log.debug("preparing splice-out for amount=$amount feerate=${feeratePerByte}sat/vb address=$address")
log.debug("preparing splice-out for amount={} feerate={}sat/vb address={}", amount, feeratePerByte, address)
val userFeerate = FeeratePerKw(FeeratePerByte(feeratePerByte))
val scriptPubKey = Parser.addressToPublicKeyScriptOrNull(chain, address)!!
val res = peerManager.getPeer().estimateFeeForSpliceOut(
@@ -100,7 +101,7 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain
) {
if (state is SpliceOutState.ReadyToSend) {
state = SpliceOutState.Executing(amount, feerate)
log.debug("executing splice-out with for=$amount feerate=${feerate}sat/vb address=$address")
log.debug("executing splice-out with for={} feerate={}sat/vb address={}", amount, feerate, address)
viewModelScope.launch(Dispatchers.Default + CoroutineExceptionHandler { _, e ->
log.error("error when executing splice-out: ", e)
state = SpliceOutState.Error.Thrown(e)
@@ -111,11 +112,11 @@ class SpliceOutViewModel(private val peerManager: PeerManager, private val chain
feerate = feerate,
)
when (response) {
is ChannelCommand.Commitment.Splice.Response.Created -> {
is ChannelFundingResponse.Success -> {
log.info("successfully executed splice-out: $response")
state = SpliceOutState.Complete.Success(amount, feerate, response)
}
is ChannelCommand.Commitment.Splice.Response.Failure -> {
is ChannelFundingResponse.Failure -> {
log.info("failed to execute splice-out: $response")
state = SpliceOutState.Complete.Failure(amount, feerate, response)
}
Original file line number Diff line number Diff line change
@@ -21,7 +21,7 @@ import com.google.firebase.messaging.FirebaseMessaging
import fr.acinq.bitcoin.TxId
import fr.acinq.lightning.LiquidityEvents
import fr.acinq.lightning.MilliSatoshi
import fr.acinq.lightning.io.PaymentReceived
import fr.acinq.lightning.PaymentEvents
import fr.acinq.lightning.utils.Connection
import fr.acinq.lightning.utils.currentTimestampMillis
import fr.acinq.phoenix.PhoenixBusiness
@@ -257,7 +257,7 @@ class NodeService : Service() {
val trustedSwapInTxs = LegacyPrefsDatastore.getMigrationTrustedSwapInTxs(applicationContext).first()
val preferredFiatCurrency = userPrefs.getFiatCurrency.first()

monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.peerManager, business.currencyManager, userPrefs) }
monitorPaymentsJob = serviceScope.launch { monitorPaymentsWhenHeadless(business.nodeParamsManager, business.currencyManager, userPrefs) }
monitorNodeEventsJob = serviceScope.launch { monitorNodeEvents(business.peerManager, business.nodeParamsManager) }
monitorFcmTokenJob = serviceScope.launch { monitorFcmToken(business) }
monitorInFlightPaymentsJob = serviceScope.launch { monitorInFlightPayments(business.peerManager) }
@@ -300,7 +300,7 @@ class NodeService : Service() {
// TODO: click on notif must deeplink to the notification screen
when (event) {
is LiquidityEvents.Rejected -> {
log.debug("processing liquidity_event=$event")
log.debug("processing liquidity_event={}", event)
if (event.source == LiquidityEvents.Source.OnChainWallet) {
// Check the last time a rejected on-chain swap notification has been shown. If recent, we do not want to trigger a notification every time.
val lastRejectedSwap = internalData.getLastRejectedOnchainSwap.first().takeIf {
@@ -327,8 +327,14 @@ class NodeService : Service() {
is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> {
SystemNotificationHelper.notifyPaymentRejectedOverRelative(applicationContext, event.source, event.amount, event.fee, reason.maxRelativeFeeBasisPoints, nextTimeout?.second)
}
LiquidityEvents.Rejected.Reason.ChannelInitializing -> {
SystemNotificationHelper.notifyPaymentRejectedChannelsInitializing(applicationContext, event.source, event.amount, nextTimeout?.second)
is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow -> {
SystemNotificationHelper.notifyPaymentRejectedAmountTooLow(applicationContext, event.source, event.amount)
}
// Temporary errors
is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress,
is LiquidityEvents.Rejected.Reason.NoMatchingFundingRate,
is LiquidityEvents.Rejected.Reason.TooManyParts -> {
SystemNotificationHelper.notifyPaymentRejectedFundingError(applicationContext, event.source, event.amount)
}
}
}
@@ -337,17 +343,18 @@ class NodeService : Service() {
}
}

private suspend fun monitorPaymentsWhenHeadless(peerManager: PeerManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) {
peerManager.getPeer().eventsFlow.collect { event ->
private suspend fun monitorPaymentsWhenHeadless(nodeParamsManager: NodeParamsManager, currencyManager: CurrencyManager, userPrefs: UserPrefsRepository) {

nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents.collect { event ->
when (event) {
is PaymentReceived -> {
is PaymentEvents.PaymentReceived -> {
if (isHeadless) {
receivedInBackground.add(event.received.amount)
receivedInBackground.add(event.amount)
SystemNotificationHelper.notifyPaymentsReceived(
context = applicationContext,
userPrefs = userPrefs,
paymentHash = event.incomingPayment.paymentHash,
amount = event.received.amount,
paymentHash = event.paymentHash,
amount = event.amount,
rates = currencyManager.ratesFlow.value,
isHeadless = isHeadless && receivedInBackground.size == 1
)
Original file line number Diff line number Diff line change
@@ -268,14 +268,13 @@ private fun PaymentNotification(
notification.fee.toPrettyString(btcUnit, withUnit = true),
notification.maxAbsoluteFee.toPrettyString(btcUnit, withUnit = true),
)

is Notification.OverRelativeFee -> stringResource(
id = R.string.inappnotif_payment_rejected_over_relative,
notification.fee.toPrettyString(btcUnit, withUnit = true),
DecimalFormat("0.##").format(notification.maxRelativeFeeBasisPoints.toDouble() / 100),
)

is Notification.ChannelsInitializing -> stringResource(id = R.string.inappnotif_payment_rejected_channel_initializing)
is Notification.MissingOffChainAmountTooLow -> stringResource(id = R.string.notif_rejected_amount_too_low)
is Notification.GenericError -> stringResource(id = R.string.notif_rejected_generic_error)
},
bottomText = when (notification) {
is Notification.OverAbsoluteFee, is Notification.OverRelativeFee, is Notification.FeePolicyDisabled -> {
Original file line number Diff line number Diff line change
@@ -64,6 +64,7 @@ import fr.acinq.phoenix.android.fiatRate
import fr.acinq.phoenix.android.security.SeedManager
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.negativeColor
import fr.acinq.phoenix.utils.extensions.phoenixName
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -115,8 +116,8 @@ class ResetWalletViewModel : ViewModel() {

state.value = ResetWalletStep.Deleting.Databases
context.deleteDatabase("appdb.sqlite")
context.deleteDatabase("payments-${chain.name.lowercase()}-$nodeIdHash.sqlite")
context.deleteDatabase("channels-${chain.name.lowercase()}-$nodeIdHash.sqlite")
context.deleteDatabase("payments-${chain.phoenixName}-$nodeIdHash.sqlite")
context.deleteDatabase("channels-${chain.phoenixName}-$nodeIdHash.sqlite")
delay(500)

state.value = ResetWalletStep.Deleting.Prefs
Original file line number Diff line number Diff line change
@@ -68,7 +68,7 @@ import fr.acinq.phoenix.android.components.PhoenixIcon
import fr.acinq.phoenix.android.components.ProgressView
import fr.acinq.phoenix.android.components.settings.Setting
import fr.acinq.phoenix.android.components.settings.SettingWithCopy
import fr.acinq.phoenix.android.components.TransactionLinkButton
import fr.acinq.phoenix.android.components.InlineTransactionLink
import fr.acinq.phoenix.android.navController
import fr.acinq.phoenix.android.navigateToPaymentDetails
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
@@ -188,7 +188,7 @@ private fun CommitmentDetailsView(
Row {
Text(text = stringResource(id = R.string.channeldetails_commitment_funding_tx_id), modifier = Modifier.alignByBaseline())
Spacer(modifier = Modifier.width(4.dp))
TransactionLinkButton(txId = commitment.fundingTxId, modifier = Modifier.alignByBaseline())
InlineTransactionLink(txId = commitment.fundingTxId, modifier = Modifier.alignByBaseline())
}
Row {
Text(text = stringResource(id = R.string.channeldetails_commitment_balance))
Original file line number Diff line number Diff line change
@@ -111,7 +111,7 @@ private fun LightningBalanceView(
if (balance != null && inboundLiquidity != null) {
val balanceVsInbound = remember(balance, inboundLiquidity) {
(balance.msat.toFloat() / (balance.msat + inboundLiquidity.msat))
.coerceIn(0.1f, 0.9f)// unreadable otherwise
.coerceIn(0.1f, if (inboundLiquidity.msat > 0) 0.9f else 1f) // unreadable otherwise
.takeUnless { it.isNaN() }
}
Row(verticalAlignment = Alignment.CenterVertically) {
Original file line number Diff line number Diff line change
@@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import fr.acinq.lightning.payment.LiquidityPolicy
import fr.acinq.lightning.utils.msat
import fr.acinq.phoenix.android.R
import fr.acinq.phoenix.android.business
import fr.acinq.phoenix.android.components.Button
@@ -111,7 +112,15 @@ fun AdvancedIncomingFeePolicy(
}

Card {
val newPolicy = maxRelativeFeeBasisPoints?.let { LiquidityPolicy.Auto(maxRelativeFeeBasisPoints = it, maxAbsoluteFee = maxAbsoluteFee, skipAbsoluteFeeCheck = skipAbsoluteFeeCheck) }
val newPolicy = maxRelativeFeeBasisPoints?.let {
LiquidityPolicy.Auto(
inboundLiquidityTarget = null,
maxRelativeFeeBasisPoints = it,
maxAbsoluteFee = maxAbsoluteFee,
skipAbsoluteFeeCheck = skipAbsoluteFeeCheck,
maxAllowedFeeCredit = 0.msat,
)
}
val isEnabled = newPolicy != null && liquidityPolicyPrefs != newPolicy
Button(
text = stringResource(id = R.string.liquiditypolicy_save_button),
Original file line number Diff line number Diff line change
@@ -36,6 +36,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import fr.acinq.bitcoin.Satoshi
import fr.acinq.lightning.payment.LiquidityPolicy
import fr.acinq.lightning.utils.msat
import fr.acinq.lightning.utils.sat
import fr.acinq.phoenix.android.LocalFiatCurrency
import fr.acinq.phoenix.android.R
@@ -132,7 +133,15 @@ fun LiquidityPolicyView(
val skipAbsoluteFeeCheck = if (liquidityPolicyPrefs is LiquidityPolicy.Auto) liquidityPolicyPrefs.skipAbsoluteFeeCheck else false
val newPolicy = when {
isPolicyDisabled -> LiquidityPolicy.Disable
else -> maxAbsoluteFee?.let { LiquidityPolicy.Auto(maxRelativeFeeBasisPoints = maxPropFeePrefs, maxAbsoluteFee = it, skipAbsoluteFeeCheck = skipAbsoluteFeeCheck) }
else -> maxAbsoluteFee?.let {
LiquidityPolicy.Auto(
inboundLiquidityTarget = null,
maxRelativeFeeBasisPoints = maxPropFeePrefs,
maxAbsoluteFee = it,
skipAbsoluteFeeCheck = skipAbsoluteFeeCheck,
maxAllowedFeeCredit = 0.msat,
)
}
}
val isEnabled = newPolicy != null && liquidityPolicyPrefs != newPolicy
Button(
Original file line number Diff line number Diff line change
@@ -23,12 +23,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -49,7 +47,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.journeyapps.barcodescanner.DecoratedBarcodeView
import fr.acinq.bitcoin.Satoshi
@@ -71,15 +68,14 @@ import fr.acinq.phoenix.android.components.FeerateSlider
import fr.acinq.phoenix.android.components.ProgressView
import fr.acinq.phoenix.android.components.SplashLabelRow
import fr.acinq.phoenix.android.components.TextInput
import fr.acinq.phoenix.android.components.TransactionLinkButton
import fr.acinq.phoenix.android.components.InlineTransactionLink
import fr.acinq.phoenix.android.components.feedback.ErrorMessage
import fr.acinq.phoenix.android.components.feedback.SuccessMessage
import fr.acinq.phoenix.android.fiatRate
import fr.acinq.phoenix.android.payments.CameraPermissionsView
import fr.acinq.phoenix.android.payments.ScannerView
import fr.acinq.phoenix.android.utils.Converter.toPrettyString
import fr.acinq.phoenix.android.utils.annotatedStringResource
import fr.acinq.phoenix.android.utils.copyToClipboard
import fr.acinq.phoenix.managers.PeerManager
import fr.acinq.phoenix.utils.Parser

@@ -276,7 +272,7 @@ private fun AvailableForRefundView(
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(text = stringResource(id = R.string.swapinrefund_success_details))
Spacer(modifier = Modifier.height(12.dp))
TransactionLinkButton(txId = currentState.tx.txid)
InlineTransactionLink(txId = currentState.tx.txid)
}
}
}
Original file line number Diff line number Diff line change
@@ -237,7 +237,8 @@ private fun ReadyForSwapView(
DecimalFormat("0.##").format(lastSwapFailedNotification.maxRelativeFeeBasisPoints.toDouble() / 100),
)
is Notification.FeePolicyDisabled -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_disabled)
is Notification.ChannelsInitializing -> stringResource(id = R.string.walletinfo_onchain_swapin_last_attempt_channels_init)
is Notification.MissingOffChainAmountTooLow -> stringResource(id = R.string.notif_rejected_amount_too_low)
is Notification.GenericError -> stringResource(id = R.string.notif_rejected_generic_error)
},
)
}
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@ import fr.acinq.phoenix.android.security.SeedManager
import fr.acinq.phoenix.android.services.NodeService
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.nodeIdHash
import fr.acinq.phoenix.utils.extensions.phoenixName
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -98,7 +99,7 @@ class StartupViewModel : ViewModel() {
val seed = MnemonicCode.toSeed(mnemonics = words.joinToString(" "), passphrase = "").byteVector()
val localKeyManager = LocalKeyManager(seed = seed, chain = NodeParamsManager.chain, remoteSwapInExtendedPublicKey = NodeParamsManager.remoteSwapInXpub)
val nodeIdHash = localKeyManager.nodeIdHash()
val channelsDbFile = context.getDatabasePath("channels-${NodeParamsManager.chain.name.lowercase()}-$nodeIdHash.sqlite")
val channelsDbFile = context.getDatabasePath("channels-${NodeParamsManager.chain.phoenixName}-$nodeIdHash.sqlite")
if (channelsDbFile.exists()) {
decryptionState.value = StartupDecryptionState.SeedInputFallback.Success.MatchingData
val encodedSeed = EncryptedSeed.fromMnemonics(words)
Loading

0 comments on commit 5fbe04e

Please sign in to comment.