diff --git a/README.md b/README.md index 75c93de07..4a9e4b8af 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,11 @@ This repository contains a **new native Android app** which is **not ready for production**. -## Prerequisites - -1. Download `google-services.json` to `./app` from FCM Console - ## Development +**Prerequisites** +1. Download `google-services.json` to `app/` from FCM Console. + ### References - For LNURL dev testing see [bitkit-docker](https://github.com/ovitrif/bitkit-docker) @@ -28,8 +27,7 @@ Recommended Android Studio plugins: - EditorConfig - Detekt -#### Commands - +**Commands** ```sh ./gradlew detekt # run analysis + formatting check ./gradlew detekt --auto-correct # auto-fix formatting issues @@ -49,11 +47,12 @@ The build config supports building 3 different apps for the 3 bitcoin networks ( ### Build for Release -**Prerequisite**: setup the signing config: -- Add the keystore file to root dir (i.e. `./release.keystore`) +**Prerequisites** +Setup the signing config: +- Add the keystore file to root dir (i.e. `release.keystore`) - Setup `keystore.properties` file in root dir (`cp keystore.properties.template keystore.properties`) -#### Routine: +**Routine** Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run: ```sh @@ -63,3 +62,8 @@ Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run: APK is generated in `app/build/outputs/apk/_flavor_/release`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet'). Example for dev: `app/build/outputs/apk/dev/release` + +## License + +This project is licensed under the MIT License. +See the [LICENSE](./LICENSE) file for more details. diff --git a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt index 9dc270e04..f91270194 100644 --- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt @@ -5,7 +5,6 @@ import com.synonym.bitkitcore.LightningInvoice import com.synonym.bitkitcore.Scanner import com.synonym.bitkitcore.createWithdrawCallbackUrl import com.synonym.bitkitcore.decode -import com.synonym.bitkitcore.getLnurlInvoice import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -41,6 +40,8 @@ import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.services.LightningService import to.bitkit.services.LnUrlWithdrawResponse +import to.bitkit.services.LnurlChannelInfoResponse +import to.bitkit.services.LnurlChannelResponse import to.bitkit.services.LnurlService import to.bitkit.services.NodeEventHandler import to.bitkit.utils.Logger @@ -378,6 +379,12 @@ class LightningRepo @Inject constructor( Result.success(Unit) } + suspend fun connectPeer(peer: LnPeer): Result = executeWhenNodeRunning("connectPeer") { + lightningService.connectPeer(peer) + syncState() + Result.success(Unit) + } + suspend fun disconnectPeer(peer: LnPeer): Result = executeWhenNodeRunning("Disconnect peer") { lightningService.disconnectPeer(peer) syncState() @@ -410,7 +417,7 @@ class LightningRepo @Inject constructor( ): Result { return runCatching { // TODO use bitkit-core getLnurlInvoice if it works with callbackUrl - val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).pr + val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).getOrThrow().pr val decoded = (decode(bolt11) as Scanner.Lightning).invoice return@runCatching decoded }.onFailure { @@ -418,7 +425,7 @@ class LightningRepo @Inject constructor( } } - suspend fun handleLnUrlWithdraw( + suspend fun handleLnurlWithdraw( k1: String, callback: String, paymentRequest: String, @@ -428,6 +435,18 @@ class LightningRepo @Inject constructor( lnurlService.fetchWithdrawInfo(callbackUrl) } + suspend fun fetchLnurlChannelInfo(url: String): Result = + lnurlService.fetchLnurlChannelInfo(url) + + suspend fun handleLnurlChannel( + k1: String, + callback: String, + nodeId: String, + ): Result = executeWhenNodeRunning("handleLnurlChannel") { + // TODO use bitkit-core createChannelRequestUrl after it is fixed to prevent k1 duplicating + lnurlService.handleLnurlChannel(k1 = k1, callback = callback, nodeId = nodeId) + } + suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result = executeWhenNodeRunning("Pay invoice") { val paymentId = lightningService.send(bolt11 = bolt11, sats = sats) diff --git a/app/src/main/java/to/bitkit/services/LnurlService.kt b/app/src/main/java/to/bitkit/services/LnurlService.kt index 42252be62..7094453a6 100644 --- a/app/src/main/java/to/bitkit/services/LnurlService.kt +++ b/app/src/main/java/to/bitkit/services/LnurlService.kt @@ -16,6 +16,8 @@ class LnurlService @Inject constructor( ) { suspend fun fetchWithdrawInfo(callbackUrl: String): Result = runCatching { + Logger.debug("Fetching LNURL withdraw info from: $callbackUrl") + val response: HttpResponse = client.get(callbackUrl) Logger.debug("Http call: $response") @@ -29,6 +31,7 @@ class LnurlService @Inject constructor( withdrawResponse.status == "ERROR" -> { throw Exception("LNURL error: ${withdrawResponse.reason}") } + else -> withdrawResponse } }.onFailure { @@ -39,11 +42,15 @@ class LnurlService @Inject constructor( callbackUrl: String, amountSats: ULong, comment: String? = null, - ): LnurlPayResponse { + ): Result = runCatching { + Logger.debug("Fetching LNURL pay invoice info from: $callbackUrl") + val response = client.get(callbackUrl) { url { - parameters.append("amount", "${amountSats * 1000u}") // convert to msat - comment?.takeIf { it.isNotBlank() }?.let { parameters.append("comment", it) } + parameters["amount"] = "${amountSats * 1000u}" // convert to msat + comment?.takeIf { it.isNotBlank() }?.let { + parameters["comment"] = it + } } } Logger.debug("Http call: $response") @@ -52,7 +59,53 @@ class LnurlService @Inject constructor( throw Exception("HTTP error: ${response.status}") } - return response.body() + return@runCatching response.body() + } + + suspend fun fetchLnurlChannelInfo(url: String): Result = runCatching { + Logger.debug("Fetching LNURL channel info from: $url") + + val response: HttpResponse = client.get(url) + Logger.debug("Http call: $response") + + if (!response.status.isSuccess()) { + throw Exception("HTTP error: ${response.status}") + } + + return@runCatching response.body() + }.onFailure { + Logger.warn(msg = "Failed to fetch channel info", e = it, context = TAG) + } + + suspend fun handleLnurlChannel( + k1: String, + callback: String, + nodeId: String, + ): Result = runCatching { + Logger.debug("Handling LNURL channel request to: $callback") + + val response = client.get(callback) { + url { + parameters["k1"] = k1 + parameters["remoteid"] = nodeId + parameters["private"] = "1" // Private channel + } + } + Logger.debug("Http call: $response") + + if (!response.status.isSuccess()) throw Exception("HTTP error: ${response.status}") + + val parsedResponse = response.body() + + when { + parsedResponse.status == "ERROR" -> { + throw Exception("LNURL channel error: ${parsedResponse.reason}") + } + + else -> parsedResponse + } + }.onFailure { + Logger.warn(msg = "Failed to handle LNURL channel", e = it, context = TAG) } companion object { @@ -70,7 +123,7 @@ data class LnUrlWithdrawResponse( val defaultDescription: String? = null, val minWithdrawable: Long? = null, val maxWithdrawable: Long? = null, - val balanceCheck: String? = null + val balanceCheck: String? = null, ) @Serializable @@ -78,3 +131,17 @@ data class LnurlPayResponse( val pr: String, val routes: List, ) + +@Serializable +data class LnurlChannelResponse( + val status: String? = null, + val reason: String? = null, +) + +@Serializable +data class LnurlChannelInfoResponse( + val uri: String, + val tag: String, + val callback: String, + val k1: String, +) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 9d796a660..28258c166 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -147,10 +147,10 @@ import to.bitkit.viewmodels.BlocktankViewModel import to.bitkit.viewmodels.CurrencyViewModel import to.bitkit.viewmodels.MainScreenEffect import to.bitkit.viewmodels.RestoreState -import to.bitkit.viewmodels.SendEvent import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel +import to.bitkit.ui.screens.transfer.external.LnurlChannelScreen @Composable fun ContentView( @@ -610,6 +610,14 @@ private fun RootNavHost( onCloseClick = { navController.navigateToHome() }, ) } + composableWithDefaultTransitions { + LnurlChannelScreen( + route = it.toRoute(), + onConnected = { navController.navigate(Routes.ExternalSuccess) }, + onBack = { navController.popBackStack() }, + onClose = { navController.navigateToHome() }, + ) + } composableWithDefaultTransitions { ExternalSuccessScreen( onContinue = { navController.popBackStack(inclusive = true) }, @@ -728,14 +736,14 @@ private fun NavGraphBuilder.shop( } ) } - composableWithDefaultTransitions { navBackEntry -> + composableWithDefaultTransitions { ShopWebViewScreen ( onClose = { navController.navigateToHome() }, onBack = { navController.popBackStack() }, - page = navBackEntry.toRoute().page, - title = navBackEntry.toRoute().title, + page = it.toRoute().page, + title = it.toRoute().title, onPaymentIntent = { data -> - appViewModel.onScanSuccess(data) + appViewModel.onScanSuccess(data) } ) } @@ -823,8 +831,8 @@ private fun NavGraphBuilder.changePinNew(navController: NavHostController) { } private fun NavGraphBuilder.changePinConfirm(navController: NavHostController) { - composableWithDefaultTransitions { navBackEntry -> - val route = navBackEntry.toRoute() + composableWithDefaultTransitions { + val route = it.toRoute() ChangePinConfirmScreen( newPin = route.newPin, navController = navController, @@ -887,9 +895,9 @@ private fun NavGraphBuilder.channelOrdersSettings( private fun NavGraphBuilder.orderDetailSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { navBackEntry -> + composableWithDefaultTransitions { OrderDetailScreen( - orderItem = navBackEntry.toRoute(), + orderItem = it.toRoute(), onBackClick = { navController.popBackStack() }, ) } @@ -898,9 +906,9 @@ private fun NavGraphBuilder.orderDetailSettings( private fun NavGraphBuilder.cjitDetailSettings( navController: NavHostController, ) { - composableWithDefaultTransitions { navBackEntry -> + composableWithDefaultTransitions { CJitDetailScreen( - cjitItem = navBackEntry.toRoute(), + cjitItem = it.toRoute(), onBackClick = { navController.popBackStack() }, ) } @@ -940,19 +948,19 @@ private fun NavGraphBuilder.activityItem( activityListViewModel: ActivityListViewModel, navController: NavHostController, ) { - composableWithDefaultTransitions { navBackEntry -> + composableWithDefaultTransitions { ActivityDetailScreen( listViewModel = activityListViewModel, - route = navBackEntry.toRoute(), + route = it.toRoute(), onExploreClick = { id -> navController.navigateToActivityExplore(id) }, onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, ) } - composableWithDefaultTransitions { navBackEntry -> + composableWithDefaultTransitions { ActivityExploreScreen( listViewModel = activityListViewModel, - route = navBackEntry.toRoute(), + route = it.toRoute(), onBackClick = { navController.popBackStack() }, onCloseClick = { navController.navigateToHome() }, ) @@ -979,8 +987,8 @@ private fun NavGraphBuilder.qrScanner( private fun NavGraphBuilder.authCheck( navController: NavHostController, ) { - composable { navBackEntry -> - val route = navBackEntry.toRoute() + composable { + val route = it.toRoute() AuthCheckScreen( route = route, navController = navController, @@ -994,8 +1002,8 @@ private fun NavGraphBuilder.logs( composableWithDefaultTransitions { LogsScreen(navController) } - composableWithDefaultTransitions { navBackEntry -> - val route = navBackEntry.toRoute() + composableWithDefaultTransitions { + val route = it.toRoute() LogDetailScreen( navController = navController, fileName = route.fileName, @@ -1565,6 +1573,9 @@ sealed interface Routes { @Serializable data object ExternalFeeCustom : Routes + @Serializable + data class LnurlChannel(val uri: String, val callback: String, val k1: String) : Routes + @Serializable data class ActivityDetail(val id: String) : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt index ca037014c..335d0857c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -62,6 +62,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import to.bitkit.R import to.bitkit.ext.clipboardManager +import to.bitkit.models.Toast import to.bitkit.ui.appViewModel import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.scaffold.AppTopBar @@ -131,13 +132,17 @@ fun QrScanningScreen( val analyzer = remember { QrCodeAnalyzer { result -> if (result.isSuccess) { - val qrCode = requireNotNull(result.getOrNull()) + val qrCode = result.getOrThrow() Logger.debug("QR code scanned: $qrCode") setScanResult(qrCode) } else { val error = requireNotNull(result.exceptionOrNull()) Logger.error("Failed to scan QR code", error) - app.toast(error) + app.toast( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__qr_error_header), + description = context.getString(R.string.other__qr_error_text), + ) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index a343452e2..342d7ee70 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -25,7 +25,6 @@ import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.services.LdkNodeEventBus -import to.bitkit.services.LightningService import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.UiState import to.bitkit.ui.shared.toast.ToastEventBus @@ -35,7 +34,6 @@ import javax.inject.Inject @HiltViewModel class ExternalNodeViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val lightningService: LightningService, private val ldkNodeEventBus: LdkNodeEventBus, private val walletRepo: WalletRepo, private val lightningRepo: LightningRepo, @@ -65,7 +63,7 @@ class ExternalNodeViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - val result = lightningService.connectPeer(peer) + val result = lightningRepo.connectPeer(peer) _uiState.update { it.copy(isLoading = false) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt new file mode 100644 index 000000000..a5fd75e02 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelScreen.kt @@ -0,0 +1,190 @@ +package to.bitkit.ui.screens.transfer.external + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import to.bitkit.R +import to.bitkit.ext.ellipsisMiddle +import to.bitkit.models.LnPeer +import to.bitkit.ui.Routes +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.FillHeight +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.CloseNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.withAccent + +@Composable +fun LnurlChannelScreen( + route: Routes.LnurlChannel, + onConnected: () -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, + viewModel: LnurlChannelViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.init(route) + } + + LaunchedEffect(uiState.isConnected) { + if (uiState.isConnected) { + onConnected() + } + } + + Content( + uiState = uiState, + onBack = onBack, + onClose = onClose, + onConnect = { viewModel.onConnect() }, + onCancel = onClose, + ) +} + +@Composable +private fun Content( + uiState: LnurlChannelUiState, + onBack: () -> Unit = {}, + onClose: () -> Unit = {}, + onConnect: () -> Unit = {}, + onCancel: () -> Unit = {}, +) { + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.other__lnurl_channel_header), + onBackClick = onBack, + actions = { CloseNavIcon(onClick = onClose) }, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + VerticalSpacer(32.dp) + + Display(stringResource(R.string.other__lnurl_channel_title).withAccent(accentColor = Colors.Purple)) + VerticalSpacer(8.dp) + + val peer = uiState.peer + if(peer != null) { + BodyM(text = stringResource(R.string.other__lnurl_channel_message), color = Colors.White64) + VerticalSpacer(48.dp) + + Caption13Up(text = stringResource(R.string.other__lnurl_channel_lsp), color = Colors.White64) + VerticalSpacer(16.dp) + + InfoRow( + label = stringResource(R.string.other__lnurl_channel_node), + value = peer.nodeId.ellipsisMiddle(24), + ) + InfoRow( + label = stringResource(R.string.other__lnurl_channel_host), + value = peer.host, + ) + InfoRow( + label = stringResource(R.string.other__lnurl_channel_port), + value = peer.port, + ) + + FillHeight() + VerticalSpacer(32.dp) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + SecondaryButton( + text = stringResource(R.string.common__cancel), + onClick = onCancel, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = stringResource(R.string.common__connect), + onClick = onConnect, + modifier = Modifier.weight(1f), + isLoading = uiState.isConnecting, + enabled = !uiState.isConnecting, + ) + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + ) { + CircularProgressIndicator() + } + } + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + CaptionB(text = label) + CaptionB(text = value) + } + HorizontalDivider() +} + +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = LnurlChannelUiState( + peer = LnPeer( + nodeId = "12345678901234567890123456789012345678901234567890", + host = "127.0.0.1", + port = "9735", + ) + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewLoading() { + AppThemeSurface { + Content( + uiState = LnurlChannelUiState(), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt new file mode 100644 index 000000000..e08d7f14b --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt @@ -0,0 +1,97 @@ +package to.bitkit.ui.screens.transfer.external + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.R +import to.bitkit.models.LnPeer +import to.bitkit.models.Toast +import to.bitkit.repositories.LightningRepo +import to.bitkit.ui.Routes +import to.bitkit.ui.shared.toast.ToastEventBus +import javax.inject.Inject + +@HiltViewModel +class LnurlChannelViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val lightningRepo: LightningRepo, +) : ViewModel() { + + private val _uiState = MutableStateFlow(LnurlChannelUiState()) + val uiState = _uiState.asStateFlow() + + private lateinit var params: Routes.LnurlChannel + + fun init(route: Routes.LnurlChannel) { + this.params = route + fetchChannelInfo() + } + + private fun fetchChannelInfo() { + viewModelScope.launch { + lightningRepo.fetchLnurlChannelInfo(params.uri) + .onSuccess { channelInfo -> + val peer = LnPeer.parseUri(channelInfo.uri).getOrElse { + errorToast(it) + return@onSuccess + } + _uiState.update { it.copy(peer = peer) } + } + .onFailure { error -> + val message = context.getString(R.string.other__lnurl_channel_error_raw) + .replace("{raw}", error.message.orEmpty()) + errorToast(Exception(message)) + } + } + } + + fun onConnect() { + val peer = requireNotNull(_uiState.value.peer) + viewModelScope.launch { + _uiState.update { it.copy(isConnecting = true) } + + val nodeId = lightningRepo.getNodeId() + if (nodeId == null) { + errorToast(Exception(context.getString(R.string.other__lnurl_ln_error_msg))) + return@launch + } + + // Connect to peer if not connected + lightningRepo.connectPeer(peer) + + lightningRepo.handleLnurlChannel(callback = params.callback, k1 = params.k1, nodeId = nodeId) + .onSuccess { + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = context.getString(R.string.other__lnurl_channel_success_title), + description = context.getString(R.string.other__lnurl_channel_success_msg_no_peer), + ) + _uiState.update { it.copy(isConnected = true) } + }.onFailure { error -> + errorToast(error) + } + + _uiState.update { it.copy(isConnecting = false) } + } + } + + suspend fun errorToast(error: Throwable) { + ToastEventBus.send( + type = Toast.ToastType.ERROR, + title = context.getString(R.string.other__lnurl_channel_error), + description = error.message ?: "Unknown error", + ) + } +} + +data class LnurlChannelUiState( + val peer: LnPeer? = null, + val isConnecting: Boolean = false, + val isConnected: Boolean = false, +) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt index 1e235f0b1..931a4ed3f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendOptionsView.kt @@ -128,13 +128,13 @@ fun SendOptionsView( onContinue = { utxos -> appViewModel.setSendEvent(SendEvent.CoinSelectionContinue(utxos)) }, ) } - composableWithDefaultTransitions { backStackEntry -> + composableWithDefaultTransitions { val uiState by appViewModel.sendUiState.collectAsStateWithLifecycle() SendAndReviewScreen( - savedStateHandle = backStackEntry.savedStateHandle, + savedStateHandle = it.savedStateHandle, uiState = uiState, onBack = { navController.popBackStack() }, - onEvent = { appViewModel.setSendEvent(it) }, + onEvent = { e -> appViewModel.setSendEvent(e) }, onClickAddTag = { navController.navigate(SendRoute.AddTag) }, onClickTag = { tag -> appViewModel.removeTag(tag) }, onNavigateToPin = { navController.navigate(SendRoute.PinCheck) }, @@ -182,7 +182,7 @@ fun SendOptionsView( }, ) } - composableWithDefaultTransitions { backStackEntry -> + composableWithDefaultTransitions { val quickPayData by appViewModel.quickPayData.collectAsStateWithLifecycle() QuickPaySendScreen( quickPayData = requireNotNull(quickPayData), @@ -194,8 +194,8 @@ fun SendOptionsView( } ) } - composableWithDefaultTransitions { backStackEntry -> - val route = backStackEntry.toRoute() + composableWithDefaultTransitions { + val route = it.toRoute() SendErrorScreen( errorMessage = route.errorMessage, onRetry = { diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt index b075e274d..278ee59e8 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt @@ -43,13 +43,11 @@ fun PinNavigationSheet( onBack = { navController.popBackStack() }, ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + composable { + val route = it.toRoute() ConfirmPinScreen( originalPin = route.pin, - onPinConfirmed = { - navController.navigate(PinRoute.AskForBiometrics) - }, + onPinConfirmed = { navController.navigate(PinRoute.AskForBiometrics) }, onBack = { navController.popBackStack() }, ) } @@ -62,8 +60,8 @@ fun PinNavigationSheet( onBack = onDismiss, ) } - composable { backStackEntry -> - val route = backStackEntry.toRoute() + composable { + val route = it.toRoute() PinResultScreen( isBioOn = route.isBioOn, onDismiss = onDismiss, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 558b1d1a5..1e882933e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.ActivityFilter import com.synonym.bitkitcore.LightningInvoice +import com.synonym.bitkitcore.LnurlChannelData import com.synonym.bitkitcore.LnurlPayData import com.synonym.bitkitcore.LnurlWithdrawData import com.synonym.bitkitcore.OnChainInvoice @@ -103,6 +104,8 @@ class AppViewModel @Inject constructor( var isGeoBlocked by mutableStateOf(null) private set + var scan: Scanner? = null; private set + private val _sendUiState = MutableStateFlow(SendUiState()) val sendUiState = _sendUiState.asStateFlow() @@ -157,8 +160,6 @@ class AppViewModel @Inject constructor( } } - private var scan: Scanner? = null - init { viewModelScope.launch { ToastEventBus.events.collect { @@ -442,10 +443,15 @@ class AppViewModel @Inject constructor( private suspend fun handleScannedData(uri: String) { val scan = runCatching { scannerService.decode(uri) } - .onFailure { Logger.error("Failed to decode: '$uri'", it) } + .onFailure { Logger.error("Failed to decode scan result: '$uri'", it) } + .onSuccess { Logger.info("Handling scan data: $it") } .getOrNull() this.scan = scan + // always reset state on new scan + resetSendState() + resetQuickPayData() + when (scan) { is Scanner.OnChain -> { val invoice: OnChainInvoice = scan.invoice @@ -495,7 +501,7 @@ class AppViewModel @Inject constructor( is Scanner.LnurlPay -> { val data = scan.data - Logger.debug("scan result: LnurlPay: $scan", context = "AppViewModel") + Logger.debug("LNURL: $data") val minSendable = data.minSendableSat() val maxSendable = data.maxSendableSat() @@ -578,7 +584,18 @@ class AppViewModel @Inject constructor( } is Scanner.LnurlAuth -> TODO("Not implemented") - is Scanner.LnurlChannel -> TODO("Not implemented") + + is Scanner.LnurlChannel -> { + val data: LnurlChannelData = scan.data + Logger.debug("LNURL: $data") + hideSheet() // hide scan sheet if opened + mainScreenEffect( + MainScreenEffect.Navigate( + Routes.LnurlChannel(uri = data.uri, callback = data.callback, k1 = data.k1) + ) + ) + } + is Scanner.NodeId -> { hideSheet() // hide scan sheet if opened val nextRoute = Routes.ExternalConnection(scan.url) @@ -893,7 +910,7 @@ class AppViewModel @Inject constructor( return@launch } - lightningService.handleLnUrlWithdraw( + lightningService.handleLnurlWithdraw( k1 = lnUrlData.data.k1, callback = lnUrlData.data.callback, paymentRequest = invoice @@ -976,8 +993,6 @@ class AppViewModel @Inject constructor( return Env.TransactionDefaults.dustLimit.toULong() } - fun resetQuickPayData() = _quickPayData.update { null } - fun clearClipboardForAutoRead() { viewModelScope.launch { val isAutoReadClipboardEnabled = settingsStore.data.first().enableAutoReadClipboard @@ -987,6 +1002,8 @@ class AppViewModel @Inject constructor( } } + fun resetQuickPayData() = _quickPayData.update { null } + fun resetSendState() { _sendUiState.value = SendUiState() scan = null