diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index ff60752c1..88a1beac6 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,5 +1,5 @@ object Versions { - const val lightningKmp = "1.7.0" + const val lightningKmp = "1.7.1-SNAPSHOT" const val secp256k1 = "0.14.0" const val torMobile = "0.2.0" diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt index 570d52567..b52ee69cc 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt @@ -478,6 +478,9 @@ fun AppView( composable(Screen.Contacts.route) { SettingsContactsView(onBackClick = { navController.popBackStack() }) } + composable(Screen.Experimental.route) { + ExperimentalView(onBackClick = { navController.popBackStack() }) + } } } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt index df3c997d8..9ce97810b 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/Navigation.kt @@ -67,6 +67,7 @@ sealed class Screen(val route: String) { data object Notifications: Screen("notifications") data object Contacts: Screen("settings/contacts") data object ResetWallet: Screen("settings/resetwallet") + data object Experimental: Screen("settings/experimental") } fun NavController.navigate(screen: Screen, arg: List = emptyList(), builder: NavOptionsBuilder.() -> Unit = {}) { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt deleted file mode 100644 index 59bab9d3a..000000000 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Settings.kt +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright 2021 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.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.material.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import fr.acinq.phoenix.android.R -import fr.acinq.phoenix.android.utils.copyToClipboard - - -@Composable -fun Setting(modifier: Modifier = Modifier, title: String, description: String?) { - Column( - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { - Text(title, style = MaterialTheme.typography.body2) - Spacer(modifier = Modifier.height(2.dp)) - Text(description ?: "", style = MaterialTheme.typography.subtitle2) - } -} - -@Composable -fun Setting(modifier: Modifier = Modifier, title: String, content: @Composable ColumnScope.() -> Unit) { - Column( - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { - Text(title, style = MaterialTheme.typography.body2) - Spacer(modifier = Modifier.height(2.dp)) - content() - } -} - -@Composable -fun SettingWithCopy( - title: String, - titleMuted: String? = null, - value: String, - maxLines: Int = Int.MAX_VALUE, -) { - val context = LocalContext.current - Row { - Column( - modifier = Modifier - .padding(start = 16.dp, top = 12.dp, bottom = 12.dp) - .weight(1f) - ) { - Row { - Text( - text = title, - style = MaterialTheme.typography.body2, - modifier = Modifier.alignByBaseline(), - ) - if (titleMuted != null) { - Spacer(Modifier.width(4.dp)) - Text( - text = titleMuted, - style = MaterialTheme.typography.subtitle2.copy(fontSize = 12.sp), - modifier = Modifier.alignByBaseline(), - ) - } - - } - Spacer(modifier = Modifier.height(2.dp)) - Text(text = value, style = MaterialTheme.typography.subtitle2, maxLines = maxLines, overflow = TextOverflow.Ellipsis) - } - Button( - icon = R.drawable.ic_copy, - onClick = { copyToClipboard(context, value, title) } - ) - } -} - -/** Static setting with composable description, and decoration to the left of the title. */ -@Composable -fun SettingWithDecoration( - modifier: Modifier = Modifier, - title: String, - description: @Composable () -> Unit = {}, - decoration: (@Composable ()-> Unit)?, -) { - Column( - modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - if (decoration != null) { - decoration() - Spacer(Modifier.width(12.dp)) - } - Text(text = title, style = MaterialTheme.typography.body2, modifier = Modifier.weight(1f)) - } - Spacer(modifier = Modifier.height(2.dp)) - Row(Modifier.fillMaxWidth()) { - if (decoration != null) { - Spacer(modifier = Modifier.width(30.dp)) - } - Column(modifier = Modifier.weight(1f)) { - CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.subtitle2) { - description() - } - } - } - } -} - -@Composable -fun SettingInteractive( - modifier: Modifier = Modifier, - title: String, - description: String, - icon: Int? = null, - enabled: Boolean = true, - onClick: (() -> Unit) -) { - SettingInteractive(modifier = modifier, title = title, icon = icon, enabled = enabled, onClick = onClick, - description = { Text(description) }) -} - -@Composable -fun SettingInteractive( - modifier: Modifier = Modifier, - title: String, - description: @Composable () -> Unit = {}, - icon: Int? = null, - iconTint: Color? = null, - maxTitleLines: Int = Int.MAX_VALUE, - enabled: Boolean = true, - onClick: (() -> Unit) -) { - Column( - modifier - .fillMaxWidth() - .clickable(onClickLabel = title, role = Role.Button, onClick = { if (enabled) onClick() }) - .enableOrFade(enabled) - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - if (icon != null) { - PhoenixIcon(icon, tint = iconTint ?: MaterialTheme.colors.onSurface, modifier = Modifier.size(ButtonDefaults.IconSize)) - Spacer(Modifier.width(12.dp)) - } - Text( - text = title, - style = MaterialTheme.typography.body2, - modifier = Modifier.weight(1f), - maxLines = maxTitleLines, - overflow = TextOverflow.Ellipsis, - ) - } - Spacer(modifier = Modifier.height(2.dp)) - Row(Modifier.fillMaxWidth()) { - if (icon != null) { - Spacer(modifier = Modifier.width(34.dp)) - } - Column(modifier = Modifier.weight(1f)) { - CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.subtitle2) { - description() - } - } - } - } -} - -@Composable -fun SettingSwitch( - modifier: Modifier = Modifier, - title: String, - description: String? = null, - icon: Int? = null, - enabled: Boolean, - isChecked: Boolean, - onCheckChangeAttempt: ((Boolean) -> Unit) -) { - Column( - modifier - .fillMaxWidth() - .clickable(onClick = { if (enabled) onCheckChangeAttempt(!isChecked) }, enabled = enabled) - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - icon?.let { - PhoenixIcon(it, Modifier.size(ButtonDefaults.IconSize)) - Spacer(Modifier.width(12.dp)) - } - Text(text = title, style = if (enabled) MaterialTheme.typography.body2 else MaterialTheme.typography.caption, modifier = Modifier.weight(1f)) - Spacer(Modifier.width(16.dp)) - Switch(checked = isChecked, onCheckedChange = null, enabled = enabled, modifier = Modifier.enableOrFade(enabled)) - } - if (description != null) { - Spacer(modifier = Modifier.height(2.dp)) - Row(Modifier.fillMaxWidth()) { - icon?.let { - Spacer(modifier = Modifier.width(30.dp)) - } - Text(text = description, style = MaterialTheme.typography.subtitle2, modifier = Modifier.weight(1f)) - Spacer(Modifier.width(48.dp)) - } - } - } -} - -@Composable -fun SettingButton( - text: String, - icon: Int, - textStyle: TextStyle = MaterialTheme.typography.button, - iconTint: Color = MaterialTheme.colors.onSurface, - enabled: Boolean = true, - onClick: () -> Unit, -) { - Button( - onClick = onClick, - text = text, - textStyle = textStyle, - icon = icon, - iconTint = iconTint, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start - ) -} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Preferences.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/Preferences.kt similarity index 91% rename from phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Preferences.kt rename to phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/Preferences.kt index d3b93f02e..90bbc54a6 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/Preferences.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/Preferences.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 ACINQ SAS + * 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package fr.acinq.phoenix.android.components +package fr.acinq.phoenix.android.components.settings import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -28,6 +28,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.Clickable +import fr.acinq.phoenix.android.components.Dialog internal data class PreferenceItem(val item: T, val title: String, val description: String? = null) @@ -35,7 +38,7 @@ internal data class PreferenceItem(val item: T, val title: String, val descri @Composable internal fun ListPreferenceButton( title: String, - subtitle: @Composable () -> Unit = {}, + subtitle: @Composable ColumnScope.() -> Unit = {}, enabled: Boolean, selectedItem: T, preferences: List>, @@ -46,9 +49,7 @@ internal fun ListPreferenceButton( ) { var showPreferenceDialog by remember { mutableStateOf(initialShowDialog) } - SettingInteractive(title = title, description = subtitle, enabled = enabled) { - showPreferenceDialog = true - } + Setting(title = title, subtitle = subtitle, enabled = enabled, onClick = { showPreferenceDialog = true }) if (showPreferenceDialog) { ListPreferenceDialog( diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/SettingSwitch.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/SettingSwitch.kt new file mode 100644 index 000000000..e346e8d5c --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/SettingSwitch.kt @@ -0,0 +1,76 @@ +/* + * 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.settings + +import androidx.compose.foundation.clickable +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import fr.acinq.phoenix.android.components.PhoenixIcon +import fr.acinq.phoenix.android.components.enableOrFade + +@Composable +fun SettingSwitch( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + icon: Int? = null, + enabled: Boolean, + isChecked: Boolean, + onCheckChangeAttempt: ((Boolean) -> Unit) +) { + Column( + modifier + .fillMaxWidth() + .clickable(role = Role.Switch, onClick = { if (enabled) onCheckChangeAttempt(!isChecked) }, enabled = enabled) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + icon?.let { + PhoenixIcon(it, Modifier.size(ButtonDefaults.IconSize)) + Spacer(Modifier.width(12.dp)) + } + Text(text = title, style = if (enabled) MaterialTheme.typography.body2 else MaterialTheme.typography.caption, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(16.dp)) + Switch(checked = isChecked, onCheckedChange = null, enabled = enabled, modifier = Modifier.enableOrFade(enabled)) + } + if (description != null) { + Spacer(modifier = Modifier.height(2.dp)) + Row(Modifier.fillMaxWidth()) { + icon?.let { + Spacer(modifier = Modifier.width(30.dp)) + } + Text(text = description, style = MaterialTheme.typography.subtitle2, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(48.dp)) + } + } + } +} \ No newline at end of file diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/Settings.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/Settings.kt new file mode 100644 index 000000000..6fb750970 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/settings/Settings.kt @@ -0,0 +1,189 @@ +/* + * 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.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +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.layout.width +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.PhoenixIcon +import fr.acinq.phoenix.android.components.enableOrFade +import fr.acinq.phoenix.android.utils.copyToClipboard + +@Composable +fun Setting( + modifier: Modifier = Modifier, + title: String, + titleNote: String? = null, + subtitle: @Composable (ColumnScope.() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + maxTitleLines: Int = Int.MAX_VALUE, + enabled: Boolean = true, + padding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + onClick: (() -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClickLabel = title, role = Role.Button, onClick = onClick, enabled = enabled) else Modifier) + .enableOrFade(enabled) + ) { + Row(modifier = Modifier.weight(1f).padding(padding)) { + if (leadingIcon != null) { + Column { + leadingIcon() + } + Spacer(modifier = Modifier.width(12.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Row { + Text( + text = title, + style = MaterialTheme.typography.body2, + modifier = Modifier.weight(1f), + maxLines = maxTitleLines, + overflow = TextOverflow.Ellipsis, + ) + if (titleNote != null) { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = titleNote, + style = MaterialTheme.typography.subtitle2.copy(fontSize = 12.sp), + modifier = Modifier.alignByBaseline(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (subtitle != null) { + Spacer(modifier = Modifier.height(2.dp)) + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.subtitle2) { + subtitle() + } + } + } + } + + if (trailingIcon != null) { + Column { + trailingIcon() + } + } + } +} + +@Composable +fun Setting(title: String, description: String?, maxDescriptionLines: Int = Int.MAX_VALUE, icon: Int? = null) { + Setting( + title = title, + subtitle = description?.let { + { Text(text = it, maxLines = maxDescriptionLines, overflow = TextOverflow.Ellipsis) } + }, + leadingIcon = icon?.let { { PhoenixIcon(resourceId = it) } }, + ) +} + +@Composable +fun Setting(title: String, subtitle: @Composable ColumnScope.() -> Unit, icon: Int? = null) { + Setting( + title = title, + subtitle = subtitle, + leadingIcon = icon?.let { { PhoenixIcon(resourceId = it) } }, + ) +} + +@Composable +fun SettingWithCopy( + title: String, + titleNote: String? = null, + value: String, + maxLines: Int = Int.MAX_VALUE, +) { + val context = LocalContext.current + Setting( + title = title, + titleNote = titleNote, + subtitle = { + Text(text = value, maxLines = maxLines, overflow = TextOverflow.Ellipsis) + }, + trailingIcon = { + Button( + icon = R.drawable.ic_copy, + onClick = { copyToClipboard(context, value, title) } + ) + }, + ) +} + +@Composable +fun Setting( + title: String, + description: String, + onClick: () -> Unit, + icon: Int? = null, + enabled: Boolean = true, +) { + Setting( + title = title, + leadingIcon = icon?.let { { PhoenixIcon(resourceId = it) } }, + enabled = enabled, onClick = onClick, + subtitle = { Text(description) }) +} + +@Composable +fun SettingButton( + text: String, + icon: Int, + textStyle: TextStyle = MaterialTheme.typography.button, + iconTint: Color = MaterialTheme.colors.onSurface, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + text = text, + textStyle = textStyle, + icon = icon, + iconTint = iconTint, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt index 367666a1c..0020e8774 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/ScanDataView.kt @@ -130,7 +130,7 @@ fun ScanDataView( } } when (model) { - Scan.Model.Ready, is Scan.Model.BadRequest, is Scan.Model.LnurlServiceFetch -> { + Scan.Model.Ready, is Scan.Model.BadRequest, is Scan.Model.LnurlServiceFetch, is Scan.Model.ResolvingBip353 -> { ReadDataView( initialInput = initialInput, model = model, @@ -294,6 +294,12 @@ fun ReadDataView( } } + if (model is Scan.Model.ResolvingBip353) { + Card(modifier = Modifier.align(Alignment.Center), internalPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) { + ProgressView(text = stringResource(R.string.scan_bip353_resolving)) + } + } + if (showManualInputDialog) { ManualInputDialog(onInputConfirm = onScannedText, onDismiss = { showManualInputDialog = false }) } @@ -354,6 +360,7 @@ private fun ScanErrorView( is Scan.BadRequestReason.InvalidLnurl -> stringResource(R.string.scan_error_lnurl_invalid) is Scan.BadRequestReason.UnsupportedLnurl -> stringResource(R.string.scan_error_lnurl_unsupported) is Scan.BadRequestReason.UnknownFormat -> stringResource(R.string.scan_error_invalid_generic) + is Scan.BadRequestReason.InvalidBip353 -> "invalid bip-353 response: ${reason.path}" } Dialog( onDismiss = onErrorDialogDismiss, @@ -418,6 +425,8 @@ private fun ManualInputDialog( text = input, onTextChange = { input = it }, modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 3, staticLabel = stringResource(id = R.string.scan_manual_input_label), ) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt index f5e798529..814be9da3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveBaseView.kt @@ -85,15 +85,25 @@ fun ReceiveView( DefaultScreenLayout(horizontalAlignment = Alignment.CenterHorizontally, isScrollable = true) { DefaultScreenHeader( - title = if (vm.isEditingLightningInvoice) { - stringResource(id = R.string.receive_lightning_edit_title) - } else null, - onBackClick = { + onBackClick = if (vm.isEditingLightningInvoice) { + { vm.isEditingLightningInvoice = false } + } else { + onBackClick + }, + content = { if (vm.isEditingLightningInvoice) { - vm.isEditingLightningInvoice = false - } else { - onBackClick() + Text(text = stringResource(id = R.string.receive_lightning_edit_title)) } + Spacer(modifier = Modifier.weight(1f)) + BorderButton( + text = stringResource(id = R.string.receive_lnurl_button), + icon = R.drawable.ic_scan, + onClick = onScanDataClick, + shape = CircleShape, + padding = PaddingValues(8.dp), + space = 6.dp, + ) + Spacer(modifier = Modifier.width(16.dp)) }, ) ReceiveViewPages(vm, onFeeManagementClick, onScanDataClick) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt index 66db0a590..694a87a88 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveLightningView.kt @@ -34,6 +34,7 @@ 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.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme @@ -81,11 +82,13 @@ import fr.acinq.phoenix.android.components.Card import fr.acinq.phoenix.android.components.Clickable import fr.acinq.phoenix.android.components.FilledButton import fr.acinq.phoenix.android.components.HSeparator +import fr.acinq.phoenix.android.components.IconPopup import fr.acinq.phoenix.android.components.ProgressView import fr.acinq.phoenix.android.components.TextInput import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.feedback.InfoMessage import fr.acinq.phoenix.android.components.feedback.WarningMessage +import fr.acinq.phoenix.android.internalData import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.borderColor @@ -198,17 +201,15 @@ fun LightningInvoiceView( ) Text( text = stringResource(id = R.string.receive_toggle_offer_new_overlay), - modifier = Modifier.rotate(-38f).offset((-9).dp, (-4).dp).background(red500).padding(horizontal = 4.dp, vertical = 2.dp), + modifier = Modifier + .rotate(-38f) + .offset((-9).dp, (-4).dp) + .background(red500) + .padding(horizontal = 4.dp, vertical = 2.dp), color = MaterialTheme.colors.onPrimary, fontSize = 12.sp ) } - Spacer(modifier = Modifier.height(16.dp)) - BorderButton( - text = stringResource(id = R.string.receive_lnurl_button), - icon = R.drawable.ic_scan, - onClick = onScanDataClick, - ) } } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOfferView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOfferView.kt index 52623685a..9bb9231d2 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOfferView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/payments/receive/ReceiveOfferView.kt @@ -16,16 +16,22 @@ package fr.acinq.phoenix.android.payments.receive +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box 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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -33,22 +39,25 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.BorderButton +import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.Clickable import fr.acinq.phoenix.android.components.TextWithIcon import fr.acinq.phoenix.android.components.openLink -import fr.acinq.phoenix.android.utils.annotatedStringResource +import fr.acinq.phoenix.android.internalData import fr.acinq.phoenix.android.utils.copyToClipboard import fr.acinq.phoenix.android.utils.share @@ -62,6 +71,9 @@ fun DisplayOfferDialog( val context = LocalContext.current val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val bip353AddressState = internalData.getBip353Address.collectAsState(initial = null) + val address = bip353AddressState.value + ModalBottomSheet( sheetState = sheetState, onDismissRequest = { @@ -89,13 +101,25 @@ fun DisplayOfferDialog( Box(modifier = Modifier.widthIn(max = 400.dp)) { QRCodeImage(bitmap = offerState.bitmap, onLongClick = { copyToClipboard(context, offerState.encoded) }) } + Bip353AddressDisplay(address) Spacer(modifier = Modifier.height(16.dp)) - CopyShareEditButtons( - onCopy = { copyToClipboard(context, data = offerState.encoded) }, - onShare = { share(context, "lightning:${offerState.encoded}", context.getString(R.string.receive_offer_share_subject), context.getString(R.string.receive_offer_share_title)) }, - onEdit = null, - maxWidth = 400.dp, - ) + Row { + var showCopyDialog by remember { mutableStateOf(false) } + if (showCopyDialog && !address.isNullOrBlank()) { + CopyAddressDialog(address = address, offer = offerState.encoded, onDismiss = { showCopyDialog = false }) + } + BorderButton( + text = stringResource(id = R.string.btn_copy), + icon = R.drawable.ic_copy, + onClick = { if (!address.isNullOrBlank()) showCopyDialog = true else copyToClipboard(context, data = offerState.encoded) }, + ) + Spacer(modifier = Modifier.width(16.dp)) + BorderButton( + text = stringResource(id = R.string.btn_share), + icon = R.drawable.ic_share, + onClick = { share(context, "lightning:${offerState.encoded}", context.getString(R.string.receive_offer_share_subject), context.getString(R.string.receive_offer_share_title)) }, + ) + } Spacer(modifier = Modifier.height(16.dp)) TorWarning() } @@ -104,6 +128,58 @@ fun DisplayOfferDialog( } } +@Composable +fun Bip353AddressDisplay(address: String?) { + when { + address.isNullOrBlank() -> {} + else -> { + SelectionContainer { + Text(text = address, style = MaterialTheme.typography.body2) + } + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CopyAddressDialog( + address: String, + offer: String, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { + // executed when user click outside the sheet, and after sheet has been hidden thru state. + onDismiss() + }, + modifier = Modifier.heightIn(max = 700.dp), + containerColor = MaterialTheme.colors.surface, + contentColor = MaterialTheme.colors.onSurface, + scrimColor = MaterialTheme.colors.onBackground.copy(alpha = 0.2f), + //dragHandle = null + ) { + Column(Modifier.padding(bottom = 50.dp)) { + Button( + text = "Copy Bolt12 code", + icon = R.drawable.ic_copy, + onClick = { copyToClipboard(context, data = offer) }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + text = "Copy address", + icon = R.drawable.ic_copy, + onClick = { copyToClipboard(context, data = address) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun HowToOffer() { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AboutView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AboutView.kt index 9d2adebc7..25ff49a21 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AboutView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AboutView.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import fr.acinq.phoenix.android.BuildConfig import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.settings.SettingButton import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.utils.annotatedStringResource diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt index c5bf911ca..682f895d0 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/AppLockView.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.settings.Setting +import fr.acinq.phoenix.android.components.settings.SettingSwitch import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.* import kotlinx.coroutines.launch @@ -58,11 +60,10 @@ private fun CanNotAuthenticate( status: Int, ) { val context = LocalContext.current - SettingInteractive( + Setting( title = stringResource(id = R.string.accessctrl_auth_error_header), - icon = R.drawable.ic_alert_triangle, - iconTint = negativeColor, - description = { Text(text = BiometricsHelper.getAuthErrorMessage(context, code = status)) }, + leadingIcon = { PhoenixIcon(resourceId = R.drawable.ic_alert_triangle, tint = negativeColor) }, + subtitle = { Text(text = BiometricsHelper.getAuthErrorMessage(context, code = status)) }, onClick = { context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) } ) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt index c79b6e38a..7bb7830e3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/DisplayPrefsView.kt @@ -30,6 +30,9 @@ import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.settings.ListPreferenceButton +import fr.acinq.phoenix.android.components.settings.PreferenceItem +import fr.acinq.phoenix.android.components.settings.Setting import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.UserTheme @@ -143,7 +146,7 @@ private fun UserThemePreference(userPrefs: UserPrefsRepository, scope: Coroutine @Composable private fun AppLocaleSetting() { val context = LocalContext.current - SettingInteractive( + Setting( title = stringResource(id = R.string.prefs_locale_label), description = Locale.getDefault().displayLanguage.replaceFirstChar { it.uppercase() }, // context.getSystemService(LocaleManager::class.java).applicationLocales.get(0).language, onClick = { diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt index cd1b5237e..d51f7ea10 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ElectrumView.kt @@ -42,6 +42,7 @@ import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* import fr.acinq.phoenix.android.components.feedback.ErrorMessage import fr.acinq.phoenix.android.components.mvi.MVIView +import fr.acinq.phoenix.android.components.settings.Setting import fr.acinq.phoenix.android.utils.* import fr.acinq.phoenix.controllers.config.ElectrumConfiguration import fr.acinq.phoenix.data.ElectrumConfig @@ -88,7 +89,7 @@ fun ElectrumView() { // -- connection detail val connection = model.connection - SettingInteractive( + Setting( title = when { connection is Connection.ESTABLISHED -> { stringResource(id = R.string.electrum_connection_connected, "${model.currentServer?.host}:${model.currentServer?.port}") @@ -106,7 +107,7 @@ fun ElectrumView() { stringResource(id = R.string.electrum_connection_closed_with_random) } }, - description = { + subtitle = { when (config) { is ElectrumConfig.Custom -> { if (connection is Connection.CLOSED && connection.isBadCertificate()) { @@ -121,11 +122,15 @@ fun ElectrumView() { else -> Unit } }, - icon = R.drawable.ic_server, - iconTint = when (connection) { - is Connection.ESTABLISHED -> positiveColor - is Connection.ESTABLISHING -> orange - else -> negativeColor + leadingIcon = { + PhoenixIcon( + resourceId = R.drawable.ic_server, + tint = when (connection) { + is Connection.ESTABLISHED -> positiveColor + is Connection.ESTABLISHING -> orange + else -> negativeColor + } + ) }, maxTitleLines = 1 ) { showCustomServerDialog = true } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt new file mode 100644 index 000000000..f33438b10 --- /dev/null +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/ExperimentalView.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2023 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.settings + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.viewModel +import fr.acinq.phoenix.android.PhoenixApplication +import fr.acinq.phoenix.android.R +import fr.acinq.phoenix.android.business +import fr.acinq.phoenix.android.components.Button +import fr.acinq.phoenix.android.components.Card +import fr.acinq.phoenix.android.components.CardHeader +import fr.acinq.phoenix.android.components.DefaultScreenHeader +import fr.acinq.phoenix.android.components.DefaultScreenLayout +import fr.acinq.phoenix.android.components.FilledButton +import fr.acinq.phoenix.android.components.PhoenixIcon +import fr.acinq.phoenix.android.components.settings.Setting +import fr.acinq.phoenix.android.utils.copyToClipboard +import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository +import fr.acinq.phoenix.managers.PeerManager +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import org.slf4j.LoggerFactory + + +sealed class ClaimAddressState { + data object Init : ClaimAddressState() + data object None : ClaimAddressState() + data object Claiming : ClaimAddressState() + data class Done(val address: String) : ClaimAddressState() + data class Failure(val e: Throwable) : ClaimAddressState() +} + +class ExperimentalViewModel(val peerManager: PeerManager, val internalDataRepository: InternalDataRepository) : ViewModel() { + val log = LoggerFactory.getLogger(this::class.java) + + var claimAddressState by mutableStateOf(ClaimAddressState.Init) + private set + + init { + viewModelScope.launch { + val address = internalDataRepository.getBip353Address.first() + if (address.isBlank()) { + claimAddressState = ClaimAddressState.None + } else { + claimAddressState = ClaimAddressState.Done(address) + } + } + } + + fun claimAddress() { + if (claimAddressState == ClaimAddressState.Claiming || claimAddressState == ClaimAddressState.Init) return + claimAddressState = ClaimAddressState.Claiming + viewModelScope.launch { + log.debug("claiming bip-353 address") + try { + + withTimeout(5_000) { + val address = peerManager.getPeer().requestAddress(languageSubtag = "en") + internalDataRepository.saveBip353Address(address) + delay(500) + log.info("successfully claimed bip-353 address=$address") + claimAddressState = ClaimAddressState.Done(address) + } + } catch (e: Exception) { + log.error("failed to claim address: ", e) + claimAddressState = ClaimAddressState.Failure(e) + } + } + } + + class Factory( + private val peerManager: PeerManager, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class, extras: CreationExtras): T { + val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication) + @Suppress("UNCHECKED_CAST") + return ExperimentalViewModel(peerManager, application.internalDataRepository) as T + } + } +} + +@Composable +fun ExperimentalView( + onBackClick: () -> Unit, +) { + val vm = viewModel(factory = ExperimentalViewModel.Factory(business.peerManager)) + + DefaultScreenLayout { + DefaultScreenHeader( + onBackClick = onBackClick, + title = stringResource(id = R.string.experimental_title) + ) + + CardHeader(text = stringResource(id = R.string.bip353_header)) + Card(modifier = Modifier.fillMaxWidth()) { + ClaimAddressButton(state = vm.claimAddressState, onClaim = { vm.claimAddress() }) + } + } +} + +@Composable +private fun ClaimAddressButton( + state: ClaimAddressState, + onClaim: () -> Unit, +) { + when (state) { + is ClaimAddressState.Init -> { + Setting( + title = stringResource(id = R.string.utils_loading_data), + description = stringResource(id = R.string.bip353_subtitle), + icon = R.drawable.ic_arobase, + ) + } + is ClaimAddressState.None -> { + Setting( + title = stringResource(id = R.string.bip353_empty), + leadingIcon = { PhoenixIcon(R.drawable.ic_arobase) }, + subtitle = { + Text(text = stringResource(id = R.string.bip353_subtitle)) + Spacer(modifier = Modifier.height(8.dp)) + FilledButton(text = stringResource(id = R.string.bip353_claim_button), onClick = onClaim, padding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)) + } + ) + } + is ClaimAddressState.Claiming -> { + Setting( + title = stringResource(id = R.string.bip353_claiming), + description = stringResource(id = R.string.bip353_subtitle), + icon = R.drawable.ic_arobase, + ) + } + is ClaimAddressState.Done -> { + val context = LocalContext.current + Setting( + title = state.address, + leadingIcon = { PhoenixIcon(R.drawable.ic_arobase) }, + trailingIcon = { Button(icon = R.drawable.ic_copy, onClick = { copyToClipboard(context, state.address) }) }, + subtitle = { + Text(text = stringResource(id = R.string.bip353_subtitle)) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = stringResource(id = R.string.bip353_subtitle2)) + }, + ) + } + is ClaimAddressState.Failure -> { + Setting( + title = stringResource(id = R.string.bip353_error), + icon = R.drawable.ic_cross_circle, + subtitle = { + Text(text = stringResource(id = R.string.bip353_subtitle)) + } + ) + } + } +} diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/LogsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/LogsView.kt index 0ee1a0bf7..452de1262 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/LogsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/LogsView.kt @@ -32,7 +32,7 @@ import fr.acinq.phoenix.android.R 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.SettingButton +import fr.acinq.phoenix.android.components.settings.SettingButton import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.utils.Logging import fr.acinq.phoenix.android.utils.shareFile diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt index 59ce46642..279e61dfc 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/PaymentSettingsView.kt @@ -33,13 +33,15 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.settings.ListPreferenceButton +import fr.acinq.phoenix.android.components.settings.PreferenceItem +import fr.acinq.phoenix.android.components.settings.Setting +import fr.acinq.phoenix.android.components.settings.SettingSwitch import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.datastore.SwapAddressFormat @@ -70,12 +72,12 @@ fun PaymentSettingsView( CardHeader(text = stringResource(id = R.string.paymentsettings_category_incoming)) Card { - SettingInteractive( + Setting( title = stringResource(id = R.string.paymentsettings_defaultdesc_title), description = invoiceDefaultDesc.ifEmpty { stringResource(id = R.string.paymentsettings_defaultdesc_none) }, onClick = { showDescriptionDialog = true } ) - SettingInteractive( + Setting( title = stringResource(id = R.string.paymentsettings_expiry_title), description = when (invoiceDefaultExpiry) { null -> stringResource(id = R.string.utils_unknown) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt index 99cee8935..f73f5abee 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/SettingsView.kt @@ -109,6 +109,7 @@ fun SettingsView( Card { MenuButton(text = stringResource(R.string.settings_wallet_info), icon = R.drawable.ic_box, onClick = { nc.navigate(Screen.WalletInfo) }) MenuButton(text = stringResource(R.string.settings_list_channels), icon = R.drawable.ic_zap, onClick = { nc.navigate(Screen.Channels) }) + MenuButton(text = "Experimental features", icon = R.drawable.ic_experimental, onClick = { nc.navigate(Screen.Experimental) }) MenuButton(text = stringResource(R.string.settings_logs), icon = R.drawable.ic_text, onClick = { nc.navigate(Screen.Logs) }) } // -- advanced diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/TorConfigView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/TorConfigView.kt index d38387a14..5eb36fa8d 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/TorConfigView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/TorConfigView.kt @@ -35,6 +35,8 @@ import fr.acinq.lightning.utils.Connection import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.settings.Setting +import fr.acinq.phoenix.android.components.settings.SettingSwitch import fr.acinq.phoenix.android.navController import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.annotatedStringResource @@ -87,14 +89,14 @@ fun TorConfigView() { if (isTorEnabled == true) { Card { val torState = connState.value.tor - SettingWithDecoration( + Setting( title = when (torState) { is Connection.CLOSED -> stringResource(R.string.tor_settings_state_closed) Connection.ESTABLISHING -> stringResource(R.string.tor_settings_state_starting) Connection.ESTABLISHED -> stringResource(R.string.tor_settings_state_started) else -> stringResource(R.string.tor_settings_state_unknown) }, - decoration = { + leadingIcon = { Row(modifier = Modifier.width(width = ButtonDefaults.IconSize), horizontalArrangement = Arrangement.Center) { Surface( shape = CircleShape, diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelDetailsView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelDetailsView.kt index 7e77c1d91..0a644d62a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelDetailsView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/channels/ChannelDetailsView.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import fr.acinq.bitcoin.ByteVector32 import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment @@ -56,7 +55,6 @@ import fr.acinq.lightning.db.WalletPayment import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business -import fr.acinq.phoenix.android.components.AmountWithFiatBelow import fr.acinq.phoenix.android.components.AmountWithFiatBeside import fr.acinq.phoenix.android.components.Button import fr.acinq.phoenix.android.components.Card @@ -66,11 +64,10 @@ import fr.acinq.phoenix.android.components.DefaultScreenLayout import fr.acinq.phoenix.android.components.Dialog import fr.acinq.phoenix.android.components.InlineButton import fr.acinq.phoenix.android.components.ItemCard +import fr.acinq.phoenix.android.components.PhoenixIcon import fr.acinq.phoenix.android.components.ProgressView -import fr.acinq.phoenix.android.components.Setting -import fr.acinq.phoenix.android.components.SettingInteractive -import fr.acinq.phoenix.android.components.SettingWithCopy -import fr.acinq.phoenix.android.components.SettingWithDecoration +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.navController import fr.acinq.phoenix.android.navigateToPaymentDetails @@ -132,20 +129,19 @@ private fun ChannelSummaryView( Setting(title = stringResource(id = R.string.channeldetails_state), description = channel.stateName) Setting( title = stringResource(id = R.string.channeldetails_spendable), - content = { + subtitle = { channel.localBalance?.let { AmountWithFiatBeside(amount = it) } ?: Text(text = stringResource(id = R.string.utils_unknown)) } ) Setting( title = stringResource(id = R.string.channeldetails_receivable), - content = { + subtitle = { channel.availableForReceive?.let { AmountWithFiatBeside(amount = it) } ?: Text(text = stringResource(id = R.string.utils_unknown)) } ) - SettingInteractive( + Setting( title = stringResource(id = R.string.channeldetails_json), - icon = R.drawable.ic_curly_braces, - iconTint = MaterialTheme.colors.primary, + leadingIcon = { PhoenixIcon(resourceId = R.drawable.ic_curly_braces, tint = MaterialTheme.colors.primary) }, onClick = { showJsonDialog = true } ) } @@ -186,9 +182,9 @@ private fun CommitmentDetailsView( value = paymentsManager.listPaymentsForTxId(commitment.fundingTxId) } - SettingWithDecoration( + Setting( title = "Index ${commitment.fundingTxIndex}", - description = { + subtitle = { Row { Text(text = stringResource(id = R.string.channeldetails_commitment_funding_tx_id), modifier = Modifier.alignByBaseline()) Spacer(modifier = Modifier.width(4.dp)) @@ -236,7 +232,6 @@ private fun CommitmentDetailsView( } } }, - decoration = null ) } diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt index 52c15c336..9aea96eef 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/AdvancedIncomingFeePolicy.kt @@ -46,7 +46,7 @@ import fr.acinq.phoenix.android.components.DefaultScreenHeader import fr.acinq.phoenix.android.components.DefaultScreenLayout import fr.acinq.phoenix.android.components.InlineNumberInput import fr.acinq.phoenix.android.components.ProgressView -import fr.acinq.phoenix.android.components.SettingSwitch +import fr.acinq.phoenix.android.components.settings.SettingSwitch import fr.acinq.phoenix.android.components.feedback.WarningMessage import fr.acinq.phoenix.android.components.enableOrFade import fr.acinq.phoenix.android.userPrefs diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt index cf0f638d9..302d0086a 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/fees/LiquidityPolicyView.kt @@ -41,6 +41,7 @@ import fr.acinq.phoenix.android.LocalFiatCurrency import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.settings.SettingSwitch import fr.acinq.phoenix.android.fiatRate import fr.acinq.phoenix.android.userPrefs import fr.acinq.phoenix.android.utils.Converter.toPrettyString diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/WalletInfoView.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/WalletInfoView.kt index 1de9c7c9c..8693c80a3 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/WalletInfoView.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/settings/walletinfo/WalletInfoView.kt @@ -45,6 +45,7 @@ import fr.acinq.phoenix.android.LocalBitcoinUnit import fr.acinq.phoenix.android.R import fr.acinq.phoenix.android.business import fr.acinq.phoenix.android.components.* +import fr.acinq.phoenix.android.components.settings.SettingWithCopy import fr.acinq.phoenix.android.utils.Converter.toPrettyString import fr.acinq.phoenix.android.utils.monoTypo import fr.acinq.phoenix.android.utils.mutedTextColor @@ -175,7 +176,7 @@ private fun FinalWalletView(onFinalWalletClick: () -> Unit) { HSeparator(modifier = Modifier.padding(start = 16.dp), width = 50.dp) SettingWithCopy( title = stringResource(id = R.string.walletinfo_xpub), - titleMuted = stringResource(id = R.string.walletinfo_path, it.finalOnChainWalletPath), + titleNote = stringResource(id = R.string.walletinfo_path, it.finalOnChainWalletPath), value = it.finalOnChainWallet.xpub, maxLines = 2, ) diff --git a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt index 33eb7c6f0..759923057 100644 --- a/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt +++ b/phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/utils/datastore/InternalDataRepository.kt @@ -56,6 +56,7 @@ class InternalDataRepository(private val internalData: DataStore) { private val INFLIGHT_PAYMENTS_COUNT = intPreferencesKey("INFLIGHT_PAYMENTS_COUNT") private val SHOW_SPLICEOUT_CAPACITY_DISCLAIMER = booleanPreferencesKey("SHOW_SPLICEOUT_CAPACITY_DISCLAIMER") private val REMOTE_WALLET_NOTICE_READ_INDEX = intPreferencesKey("REMOTE_WALLET_NOTICE_READ_INDEX") + private val BIP_353_ADDRESS = stringPreferencesKey("BIP_353_ADDRESS") } val log = LoggerFactory.getLogger(this::class.java) @@ -143,4 +144,7 @@ class InternalDataRepository(private val internalData: DataStore) { val getLastReadWalletNoticeIndex: Flow = safeData.map { it[REMOTE_WALLET_NOTICE_READ_INDEX] ?: -1 } suspend fun saveLastReadWalletNoticeIndex(index: Int) = internalData.edit { it[REMOTE_WALLET_NOTICE_READ_INDEX] = index } + + val getBip353Address: Flow = safeData.map { it[BIP_353_ADDRESS] ?: "" } + suspend fun saveBip353Address(address: String) = internalData.edit { it[BIP_353_ADDRESS] = address } } \ No newline at end of file diff --git a/phoenix-android/src/main/res/drawable/ic_arobase.xml b/phoenix-android/src/main/res/drawable/ic_arobase.xml index 689250f2d..145dc1edc 100644 --- a/phoenix-android/src/main/res/drawable/ic_arobase.xml +++ b/phoenix-android/src/main/res/drawable/ic_arobase.xml @@ -1,36 +1,20 @@ - - - - + + diff --git a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml index c24b43e97..cce096df6 100644 --- a/phoenix-android/src/main/res/values-b+es+419/important_strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/important_strings.xml @@ -446,4 +446,9 @@ Transacción publicada. Puede encontrar la transacción a continuación. No aparecerá en su historial de pagos, así que haga una copia ahora si lo necesita. + + + Esta es una dirección humanamente legible para su solicitud de pago Bolt12. + ¿Quieres una dirección más bonita? Utilice servicios de terceros como twelve.cash o aloje usted mismo la dirección. + diff --git a/phoenix-android/src/main/res/values-b+es+419/strings.xml b/phoenix-android/src/main/res/values-b+es+419/strings.xml index e5d7f5c7a..be845c5a6 100644 --- a/phoenix-android/src/main/res/values-b+es+419/strings.xml +++ b/phoenix-android/src/main/res/values-b+es+419/strings.xml @@ -111,7 +111,7 @@ No se pudo generar la factura Factura de Lightning Compartir esta dirección de Lightning con… - Usar enlace LNURL + Escanear Editar Editar factura diff --git a/phoenix-android/src/main/res/values-cs/important_strings.xml b/phoenix-android/src/main/res/values-cs/important_strings.xml index 74c3e8bb2..29a04df17 100644 --- a/phoenix-android/src/main/res/values-cs/important_strings.xml +++ b/phoenix-android/src/main/res/values-cs/important_strings.xml @@ -457,4 +457,9 @@ Transakce odeslána. Transakci naleznete níže. Nebude se zobrazovat v historii plateb, proto si nyní v případě potřeby zkopírujte její ID. + + + Toto je lidsky čitelná adresa pro vaši žádost o platbu Bolt12. + Chcete hezčí adresu? Použijte služby třetích stran, jako je twelve.cash, nebo si adresu hostujte sami! + diff --git a/phoenix-android/src/main/res/values-cs/strings.xml b/phoenix-android/src/main/res/values-cs/strings.xml index be338084a..74456d808 100644 --- a/phoenix-android/src/main/res/values-cs/strings.xml +++ b/phoenix-android/src/main/res/values-cs/strings.xml @@ -90,12 +90,12 @@ Nepodařilo se vygenerovat fakturu Lightningová faktura Sdílet tuto Lightningovou fakturu s… - Použít LNURL odkaz + Skenování NOVINKA! Statická Lightning faktura Sdílet tuto statickou Lightning fakturu s… - + Upravit Upravit fakturu Částka (volitelné) diff --git a/phoenix-android/src/main/res/values-de/important_strings.xml b/phoenix-android/src/main/res/values-de/important_strings.xml index f86e085c4..284862400 100644 --- a/phoenix-android/src/main/res/values-de/important_strings.xml +++ b/phoenix-android/src/main/res/values-de/important_strings.xml @@ -455,4 +455,9 @@ Transaktion veröffentlicht. Sie können die Transaktion unten finden. Sie wird nicht in Ihrem Zahlungsverlauf erscheinen, also machen Sie bei Bedarf jetzt eine Kopie davon. + + + Dies ist eine von Menschen lesbare Adresse für Ihre Bolt12-Zahlungsanfrage. + Wollen Sie eine hübschere Adresse? Verwenden Sie Dienste von Drittanbietern wie twelve.cash, oder hosten Sie die Adresse selbst! + \ No newline at end of file diff --git a/phoenix-android/src/main/res/values-de/strings.xml b/phoenix-android/src/main/res/values-de/strings.xml index 69c119ded..3a169bd9e 100644 --- a/phoenix-android/src/main/res/values-de/strings.xml +++ b/phoenix-android/src/main/res/values-de/strings.xml @@ -113,7 +113,7 @@ Rechnung konnte nicht erstellt werden Lightning-Rechnung Teile diese Lightning-Rechnung mit… - Nutze LNURL-Link + Scannen Ändern Rechnung ändern diff --git a/phoenix-android/src/main/res/values-es/important_strings.xml b/phoenix-android/src/main/res/values-es/important_strings.xml index 83dcd1b67..120e0cf22 100644 --- a/phoenix-android/src/main/res/values-es/important_strings.xml +++ b/phoenix-android/src/main/res/values-es/important_strings.xml @@ -458,4 +458,9 @@ Transacción publicada. Puede encontrar la transacción a continuación. No aparecerá en su historial de pagos, así que haga una copia ahora si lo necesita. + + + Esta es una dirección humanamente legible para su solicitud de pago Bolt12. + ¿Quieres una dirección más bonita? Utilice servicios de terceros como twelve.cash o aloje usted mismo la dirección. + \ No newline at end of file diff --git a/phoenix-android/src/main/res/values-fr/important_strings.xml b/phoenix-android/src/main/res/values-fr/important_strings.xml index 68e2df5d5..29fa33b2c 100644 --- a/phoenix-android/src/main/res/values-fr/important_strings.xml +++ b/phoenix-android/src/main/res/values-fr/important_strings.xml @@ -458,4 +458,9 @@ Transaction publiée. Vous pouvez trouver la transaction ci-dessous. Elle n\'apparaîtra pas dans votre historique de paiements, donc faites en copie maintenant si besoin. + + + Il s\'agit d\'une adresse humainement compréhensible pour votre requête de paiement Bolt12. + Vous voulez une adresse plus jolie ? Utilisez des services tiers comme twelve.cash, ou hébergez vous-même l\'adresse! + \ No newline at end of file diff --git a/phoenix-android/src/main/res/values-fr/strings.xml b/phoenix-android/src/main/res/values-fr/strings.xml index 09f0c45a3..92f8c162a 100644 --- a/phoenix-android/src/main/res/values-fr/strings.xml +++ b/phoenix-android/src/main/res/values-fr/strings.xml @@ -91,7 +91,7 @@ Échec de la création de la requête Paiement Lightning Partagez cette requête de paiement Lightning avec… - Utiliser un lien LNURL + Scan Éditer Éditer une requête diff --git a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml index f431a5ae5..e5ab251ae 100644 --- a/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml +++ b/phoenix-android/src/main/res/values-pt-rBR/important_strings.xml @@ -444,7 +444,7 @@ Use esta tela para gastar depósitos na cadeia que não tenham sido transferidos para o Lightning a tempo. Observe que isso não afeta seus canais Lightning de forma alguma. Verifique se o endereço de destino está correto e se as cobranças são razoáveis. Estimar taxas - Estimativa de taxas... + Estimativa de taxas… Enviar Enviando… Erro de transação. @@ -453,4 +453,9 @@ Transação publicada. Você pode encontrar a transação abaixo. Ela não aparecerá em seu histórico de pagamentos, portanto, faça uma cópia agora, se necessário. + + + Este é um endereço legível por humanos para sua solicitação de pagamento do Bolt12. + Quer um endereço mais bonito? Use serviços de terceiros, como twelve.cash, ou hospede o endereço por conta própria! + \ No newline at end of file diff --git a/phoenix-android/src/main/res/values-sk/important_strings.xml b/phoenix-android/src/main/res/values-sk/important_strings.xml index de41b84d6..247b8adfc 100644 --- a/phoenix-android/src/main/res/values-sk/important_strings.xml +++ b/phoenix-android/src/main/res/values-sk/important_strings.xml @@ -458,4 +458,9 @@ Zverejnená transakcia. Transakciu nájdete nižšie. Nebude sa zobrazovať v histórii platieb, preto si teraz vytvorte jej kópiu, ak ju potrebujete. + + + Toto je ľudsky čitateľná adresa pre vašu žiadosť o platbu Bolt12. + Chcete krajšiu adresu? Použite služby tretích strán, ako je twelve.cash, alebo si adresu vytvorte sami! + diff --git a/phoenix-android/src/main/res/values-sk/strings.xml b/phoenix-android/src/main/res/values-sk/strings.xml index 3f796c44c..20149e901 100644 --- a/phoenix-android/src/main/res/values-sk/strings.xml +++ b/phoenix-android/src/main/res/values-sk/strings.xml @@ -113,7 +113,7 @@ Nepodarilo sa vygenerovať faktúru Lightningová faktúra Zdieľať túto Lightningovú faktúru s… - Použiť LNURL odkaz + Skenovanie NOVÉ! Statická Lightningová faktúra diff --git a/phoenix-android/src/main/res/values-vi/important_strings.xml b/phoenix-android/src/main/res/values-vi/important_strings.xml index 1303f218c..7de7418d4 100644 --- a/phoenix-android/src/main/res/values-vi/important_strings.xml +++ b/phoenix-android/src/main/res/values-vi/important_strings.xml @@ -465,4 +465,9 @@ Giao dịch đã được công bố. Bạn có thể tìm thấy giao dịch chi tiêu số tiền bên dưới. Nó sẽ không xuất hiện trong lịch sử thanh toán của bạn, vì vậy hãy tạo bản sao ID của nó ngay bây giờ nếu cần. + + + Đây là địa chỉ mà con người có thể đọc được cho yêu cầu thanh toán Bolt12 của bạn. + Bạn muốn một địa chỉ đẹp hơn? Sử dụng các dịch vụ của bên thứ ba như Twelve.cash hoặc tự lưu trữ địa chỉ! + diff --git a/phoenix-android/src/main/res/values-vi/strings.xml b/phoenix-android/src/main/res/values-vi/strings.xml index cf2e8621b..bab371d2f 100644 --- a/phoenix-android/src/main/res/values-vi/strings.xml +++ b/phoenix-android/src/main/res/values-vi/strings.xml @@ -114,7 +114,7 @@ Không thể xuất hóa đơn Hoá đơn Lightning Chia sẻ hóa đơn Lightning này với… - Sử dụng liên kết LNURL + Quét Chỉnh sửa Chỉnh sửa hoá đơn diff --git a/phoenix-android/src/main/res/values/important_strings.xml b/phoenix-android/src/main/res/values/important_strings.xml index 14cae0044..0c7bada4f 100644 --- a/phoenix-android/src/main/res/values/important_strings.xml +++ b/phoenix-android/src/main/res/values/important_strings.xml @@ -460,4 +460,9 @@ Transaction published. You can find the transaction spending the funds below. It will not appear in your payments history, so make a copy of its ID now if needed. + + + This is a human-readable address for your Bolt12 payment request. + Want a prettier address? Use third-party services like twelve.cash, or self-host the address! + \ No newline at end of file diff --git a/phoenix-android/src/main/res/values/strings.xml b/phoenix-android/src/main/res/values/strings.xml index e4eac87c7..6cd6f778c 100644 --- a/phoenix-android/src/main/res/values/strings.xml +++ b/phoenix-android/src/main/res/values/strings.xml @@ -124,7 +124,7 @@ Could not generate invoice Lightning invoice Share this Lightning invoice with… - Use LNURL link + Scan NEW! Static Lightning invoice @@ -195,6 +195,7 @@ Camera permission has been denied Fetching data from service… + Resolving payment request over DNS… Manual input Enter a Lightning invoice, LNURL or Lightning address you want to send money to. @@ -224,6 +225,7 @@ Go back Next Copy + Share OK Save Confirm @@ -837,4 +839,14 @@ Contacts allows you to link a name to a static offer. They are stored locally. + + + Experimental features + + Bip353 DNS address + No address yet… + Claim my address + Claiming address… + Failed to claim address + diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt index 528d69912..87f2ca0e1 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/Scan.kt @@ -16,6 +16,7 @@ package fr.acinq.phoenix.controllers.payments +import fr.acinq.bitcoin.PublicKey import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.Satoshi import fr.acinq.lightning.MilliSatoshi @@ -41,6 +42,7 @@ object Scan { data class ChainMismatch(val expected: Chain) : BadRequestReason() data class ServiceError(val url: Url, val error: LnurlError.RemoteFailure) : BadRequestReason() data class InvalidLnurl(val url: Url) : BadRequestReason() + data class InvalidBip353(val path: String) : BadRequestReason() data class UnsupportedLnurl(val url: Url) : BadRequestReason() } @@ -82,6 +84,7 @@ object Scan { data class OnchainFlow(val uri: BitcoinUri): Model() object LnurlServiceFetch : Model() + object ResolvingBip353 : Model() sealed class LnurlPayFlow : Model() { abstract val paymentIntent: LnurlPay.Intent diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt index d4ef7ed39..f7397e37b 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/controllers/payments/ScanController.kt @@ -19,10 +19,12 @@ package fr.acinq.phoenix.controllers.payments import fr.acinq.bitcoin.BitcoinError import fr.acinq.bitcoin.Chain import fr.acinq.bitcoin.utils.Either +import fr.acinq.bitcoin.utils.Try import fr.acinq.lightning.* import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.io.PayInvoice import fr.acinq.lightning.logging.LoggerFactory +import fr.acinq.lightning.logging.debug import fr.acinq.lightning.utils.* import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.controllers.AppController @@ -31,19 +33,29 @@ import fr.acinq.phoenix.data.lnurl.* import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow import fr.acinq.phoenix.managers.* import fr.acinq.phoenix.utils.Parser -import fr.acinq.phoenix.utils.extensions.chain import fr.acinq.lightning.logging.error import fr.acinq.lightning.logging.info +import fr.acinq.lightning.logging.warning import fr.acinq.lightning.payment.Bolt11Invoice import fr.acinq.lightning.wire.OfferTypes +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText import io.ktor.http.Url +import io.ktor.serialization.kotlinx.json.json +import io.ktor.utils.io.charsets.Charsets import kotlinx.coroutines.* import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -import kotlin.time.ExperimentalTime import kotlin.time.TimeSource class AppScanController( @@ -110,6 +122,11 @@ class AppScanController( processBolt11Invoice(it) } ?: Parser.readOffer(input)?.let { processOffer(it) + } ?: readEmailLikeAddress(input)?.let { + when (it) { + is Either.Left -> processOffer(it.value) + is Either.Right -> processLnurl(it.value) + } } ?: readLnurl(input)?.let { processLnurl(it) } ?: readBitcoinAddress(input)?.let { @@ -414,7 +431,6 @@ class AppScanController( ) } - @OptIn(ExperimentalTime::class) private suspend fun processLnurlAuth( intent: Scan.Intent.LnurlAuthFlow.Login ) { @@ -494,6 +510,92 @@ class AppScanController( } } + private suspend fun readEmailLikeAddress(input: String): Either? { + + if (!input.contains("@", ignoreCase = true)) return null + + // Ignore excess input, including additional lines, and leading/trailing whitespace + val line = input.lines().firstOrNull { it.isNotBlank() }?.trim() + val token = line?.split("\\s+".toRegex())?.firstOrNull() + + if (token.isNullOrBlank()) return null + + val components = token.split("@") + if (components.size != 2) { + throw RuntimeException("identifier must contain one @ delimiter") + } + + val username = components[0].lowercase() + val domain = components[1] + + return resolveBip353Offer(username, domain)?.let { + Either.Left(it) + } ?: Either.Right(Lnurl.Request(Url("https://$domain/.well-known/lnurlp/$username"), tag = Lnurl.Tag.Pay)) + } + + val bip353HttpClient: HttpClient by lazy { + HttpClient { + install(ContentNegotiation) { + json(json = Json { ignoreUnknownKeys = true }) + expectSuccess = true + } + } + } + + /** Resolve dns-based offers. See https://github.com/bitcoin/bips/blob/master/bip-0353.mediawiki. */ + private suspend fun resolveBip353Offer( + username: String, + domain: String, + ): OfferTypes.Offer? { + model(Scan.Model.ResolvingBip353) + val dnsPath = "$username.user._bitcoin-payment.$domain." + + // list of resolvers: https://dnsprivacy.org/public_resolvers/ + val url = Url("https://dns.google/resolve?name=$dnsPath&type=TXT") + + try { + val response = bip353HttpClient.get(url) + val json = Json.decodeFromString(response.bodyAsText(Charsets.UTF_8)) + logger.info { "dns resolved to ${json.toString().take(100)}" } + + val status = json["Status"]?.jsonPrimitive?.intOrNull + if (status == null || status > 0) throw RuntimeException("invalid status=$status") + + val ad = json["AD"]?.jsonPrimitive?.booleanOrNull + if (ad != true) { + logger.info { "AD false, abort dns lookup to $url" } + return null + } + + val records = json["Answer"]?.jsonArray + if (records.isNullOrEmpty()) { + logger.debug { "no records for $url" } + return null + } + + val matchingRecord = records.filterIsInstance().firstOrNull { + logger.debug { "inspecting record=$it" } + it["name"]?.jsonPrimitive?.content == dnsPath + } ?: return null + + val data = matchingRecord["data"]?.jsonPrimitive?.content ?: return null + if (!data.startsWith("bitcoin:")) return null + val offerString = data.substringAfter("lno=").substringBefore("?") + if (offerString.isBlank()) return null + + return when (val offer = OfferTypes.Offer.decode(offerString)) { + is Try.Success -> { offer.result } + is Try.Failure -> { + model(Scan.Model.BadRequest(request = url.toString(), reason = Scan.BadRequestReason.InvalidBip353(dnsPath))) + null + } + } + } catch (e: Exception) { + logger.error(e) { "error when resolving offer on $dnsPath: ${e.message}" } + return null + } + } + /** Reads a lnurl and return either a lnurl-auth (i.e. a http query that must not be called automatically), or the actual url embedded in the lnurl (that can be called afterwards). */ private fun readLnurl(input: String): Lnurl? = try { Lnurl.extractLnurl(input, logger) diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt index 488f25006..e89ae863d 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/lnurl/Lnurl.kt @@ -83,8 +83,6 @@ sealed interface Lnurl { try { if (lud17Schemes.any { input.startsWith(it, ignoreCase = true) }) { parseNonBech32Lud17(input, logger) - } else if (input.contains('@')) { - parseInternetIdentifier(input) } else { parseNonBech32Http(input) } @@ -167,33 +165,6 @@ sealed interface Lnurl { }.build() } - /** LUD-16 support: https://github.com/fiatjaf/lnurl-rfc/blob/luds/16.md */ - private fun parseInternetIdentifier(source: String): Url { - - // Ignore excess input, including additional lines, and leading/trailing whitespace - val line = source.lines().firstOrNull { it.isNotBlank() }?.trim() ?: throw RuntimeException("identifier has an empty leading line") - val token = line.split("\\s+".toRegex()).firstOrNull() ?: throw RuntimeException("identifier has invalid chars") - - // The format is: @ - // - // The username is technically limited to: a-z0-9-_. - // But we don't enforce it, as it's a bit restrictive for a global audience. - // - // Note that, in the real world, users will type with capital letters. - // So we need to auto-convert to lowercase. - - val components = token.split("@") - if (components.size != 2) { - throw RuntimeException("identifier must contain one @ delimiter") - } - - val username = components[0].lowercase() - val domain = components[1] - - // May throw an exception if domain is invalid - return Url("https://$domain/.well-known/lnurlp/$username") - } - /** * Processes a HTTP response replied by a lnurl service and returns a [JsonObject]. * diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt index a9ea4a67d..4b57ec682 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/utils/Parser.kt @@ -81,7 +81,6 @@ object Parser { return when (val res = OfferTypes.Offer.decode(cleanInput)) { is Try.Success -> res.get() is Try.Failure -> { - println("invalid offer=$input") null } }