From 7fad5ec9ddd3cd05b37b8a7ef13c50ea310cb37b Mon Sep 17 00:00:00 2001 From: rasputin Date: Tue, 9 Jun 2026 15:48:38 +0200 Subject: [PATCH] feat: nwc --- .../gudariwallet/api/LNbitsApi.kt | 59 ++ .../bitcointxoko/gudariwallet/api/Models.kt | 40 ++ .../data/LNbitsWalletRepository.kt | 130 ++++ .../gudariwallet/data/WalletRepository.kt | 18 + .../bitcointxoko/gudariwallet/ui/HomeTab.kt | 407 +++++++------ .../gudariwallet/ui/WalletScreen.kt | 25 + .../gudariwallet/ui/nwc/NwcScreen.kt | 559 ++++++++++++++++++ .../gudariwallet/ui/nwc/NwcViewModel.kt | 175 ++++++ .../gudariwallet/util/NwcKeyGenerator.kt | 69 +++ 9 files changed, 1288 insertions(+), 194 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/util/NwcKeyGenerator.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt index 9f6764d..b759d08 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsApi.kt @@ -1,9 +1,11 @@ package com.bitcointxoko.gudariwallet.api import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Header import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query import retrofit2.http.Url @@ -144,4 +146,61 @@ interface LNbitsApi { */ @GET("api/v1/currencies") suspend fun getSupportedCurrencies(): List + + // ── NWC Provider ────────────────────────────────────────────────────────── + + @GET("nwcprovider/api/v1/permissions") + suspend fun getNwcPermissions( + @Header("X-Api-Key") apiKey: String + ): Map + + @GET("nwcprovider/api/v1/nwc") + suspend fun getNwcKeys( + @Header("X-Api-Key") adminKey: String, + @Query("include_expired") includeExpired: Boolean = false + ): List + + @GET("nwcprovider/api/v1/nwc/{pubkey}") + suspend fun getNwcKey( + @Header("X-Api-Key") apiKey: String, + @Path("pubkey") pubkey: String, + @Query("include_expired") includeExpired: Boolean = false, + @Query("refresh_last_used") refreshLastUsed: Boolean = false + ): NwcGetResponse + + @PUT("nwcprovider/api/v1/nwc/{pubkey}") + suspend fun registerNwcKey( + @Header("X-Api-Key") adminKey: String, + @Path("pubkey") pubkey: String, + @Body request: NwcRegistrationRequest + ): NwcGetResponse + + @DELETE("nwcprovider/api/v1/nwc/{pubkey}") + suspend fun deleteNwcKey( + @Header("X-Api-Key") adminKey: String, + @Path("pubkey") pubkey: String + ) + + @GET("nwcprovider/api/v1/pairing/{secret}") + suspend fun getNwcPairingUrl( + @Header("X-Api-Key") apiKey: String, + @Path("secret") secret: String + ): String + + @GET("nwcprovider/api/v1/config") + suspend fun getNwcConfig( + @Header("X-Api-Key") adminKey: String + ): Map + + @GET("nwcprovider/api/v1/config/{key}") + suspend fun getNwcConfigValue( + @Header("X-Api-Key") adminKey: String, + @Path("key") key: String + ): Map + + @POST("nwcprovider/api/v1/config") + suspend fun setNwcConfig( + @Header("X-Api-Key") adminKey: String, + @Body config: Map + ): Map } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt index 27d7921..dbf008c 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/Models.kt @@ -322,3 +322,43 @@ data class PaginatedPaymentsResponse( @SerializedName("data") val data: List, @SerializedName("total") val total: Int ) + +// ── NWC Provider models ─────────────────────────────────────────────────────── + +data class NwcKey( + val pubkey: String, + val wallet: String, + val description: String, + val expires_at: Long, + val permissions: String, // comma-separated permission names + val created_at: Long, + val last_used: Long +) + +data class NwcBudget( + val id: Int, + val pubkey: String, + val budget_msats: Long, + val refresh_window: Int, // seconds + val created_at: Long, + val used_budget_msats: Long = 0 +) + +/** Response for GET /nwc and GET /nwc/{pubkey} */ +data class NwcGetResponse( + val data: NwcKey, + val budgets: List +) + +data class NwcNewBudget( + val budget_msats: Long, + val refresh_window: Int // seconds +) + +/** Request body for PUT /nwc/{pubkey} */ +data class NwcRegistrationRequest( + val permissions: List, + val description: String, + val expires_at: Long, // unix timestamp; 0 = no expiry + val budgets: List = emptyList() +) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt index fabbc38..f60c926 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt @@ -286,4 +286,134 @@ class LNbitsWalletRepository( override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse { return api.getPaymentDetail(secrets.invoiceKey(), checkingId) } + + // ── NWC Provider ─────────────────────────────────────────────────────────── + + /** + * Returns the map of supported NWC permission names. + */ + override suspend fun getNwcPermissions(): Map { + Timber.d("NWC [PERMISSIONS] fetching supported permissions") + val result = api.getNwcPermissions(secrets.invoiceKey()) + Timber.i("NWC [PERMISSIONS] received ${result.size} permission(s)") + return result + } + + /** + * Returns all NWC connection keys for the current wallet. + */ + override suspend fun getNwcKeys(includeExpired: Boolean): List { + Timber.d("NWC [KEYS ] fetching all keys (includeExpired=$includeExpired)") + val result = api.getNwcKeys(secrets.adminKey(), includeExpired) + Timber.i("NWC [KEYS ] received ${result.size} key(s)") + return result + } + + /** + * Returns a single NWC connection key by its public key. + */ + override suspend fun getNwcKey( + pubkey : String, + includeExpired : Boolean, + refreshLastUsed : Boolean + ): NwcGetResponse { + Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}…") + val result = api.getNwcKey( + apiKey = secrets.invoiceKey(), + pubkey = pubkey, + includeExpired = includeExpired, + refreshLastUsed = refreshLastUsed + ) + Timber.i("NWC [KEY ] fetched key pubkey=${pubkey.take(16)}… description='${result.data.description}'") + return result + } + + /** + * Registers / creates a new NWC connection key. + * Requires admin key. + */ + override suspend fun registerNwcKey( + pubkey : String, + request : NwcRegistrationRequest + ): NwcGetResponse { + Timber.d( + "NWC [REGISTER ] pubkey=${pubkey.take(16)}… " + + "permissions=${request.permissions} " + + "expires_at=${request.expires_at} " + + "budgets=${request.budgets.size}" + ) + val result = api.registerNwcKey( + adminKey = secrets.adminKey(), + pubkey = pubkey, + request = request + ) + Timber.i("NWC [REGISTER ] registered key pubkey=${pubkey.take(16)}… ✓") + return result + } + + /** + * Deletes an NWC connection key by its public key. + * Requires admin key. + */ + override suspend fun deleteNwcKey(pubkey: String) { + Timber.d("NWC [DELETE ] pubkey=${pubkey.take(16)}…") + api.deleteNwcKey( + adminKey = secrets.adminKey(), + pubkey = pubkey + ) + Timber.i("NWC [DELETE ] deleted key pubkey=${pubkey.take(16)}… ✓") + } + + /** + * Returns the pairing URL (nostr+walletconnect://…) for a given secret. + */ + override suspend fun getNwcPairingUrl(secret: String): String { + Timber.d("NWC [PAIRING ] fetching pairing URL for secret=${secret.take(8)}…") + val url = api.getNwcPairingUrl( + apiKey = secrets.invoiceKey(), + secret = secret + ) + Timber.i("NWC [PAIRING ] pairing URL received ✓") + return url + } + + /** + * Returns all extension configuration settings. + * Requires admin key. + */ + override suspend fun getNwcConfig(): Map { + Timber.d("NWC [CONFIG ] fetching all config settings") + val result = api.getNwcConfig(secrets.adminKey()) + Timber.i("NWC [CONFIG ] received ${result.size} setting(s)") + return result + } + + /** + * Returns a single configuration value by key. + * Requires admin key. + */ + override suspend fun getNwcConfigValue(key: String): Map { + Timber.d("NWC [CONFIG ] fetching config key='$key'") + val result = api.getNwcConfigValue( + adminKey = secrets.adminKey(), + key = key + ) + Timber.i("NWC [CONFIG ] received value for key='$key'") + return result + } + + /** + * Sets / updates one or more configuration values. + * Requires admin key. + */ + override suspend fun setNwcConfig(config: Map): Map { + Timber.d("NWC [CONFIG SET] updating ${config.size} setting(s): keys=${config.keys}") + val result = api.setNwcConfig( + adminKey = secrets.adminKey(), + config = config + ) + Timber.i("NWC [CONFIG SET] config updated ✓") + return result + } + } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/WalletRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/WalletRepository.kt index 05d7a3d..35444f6 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/WalletRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/WalletRepository.kt @@ -1,6 +1,8 @@ package com.bitcointxoko.gudariwallet.data import com.bitcointxoko.gudariwallet.api.LnurlScanResponse +import com.bitcointxoko.gudariwallet.api.NwcGetResponse +import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse import com.bitcointxoko.gudariwallet.api.PaymentRecord @@ -41,4 +43,20 @@ interface WalletRepository { suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse suspend fun getFiatRate(currency: String): Double suspend fun getSupportedCurrencies(): List + suspend fun getNwcPermissions(): Map + suspend fun getNwcKeys(includeExpired: Boolean = false): List + suspend fun getNwcKey( + pubkey : String, + includeExpired : Boolean = false, + refreshLastUsed : Boolean = false + ): NwcGetResponse + suspend fun registerNwcKey( + pubkey : String, + request : NwcRegistrationRequest + ): NwcGetResponse + suspend fun deleteNwcKey(pubkey: String) + suspend fun getNwcPairingUrl(secret: String): String + suspend fun getNwcConfig(): Map + suspend fun getNwcConfigValue(key: String): Map + suspend fun setNwcConfig(config: Map): Map } \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt index 8e84b18..01a40f6 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/HomeTab.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Language // ← NEW import androidx.compose.ui.text.input.ImeAction import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.filled.Cable import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.platform.LocalFocusManager import com.bitcointxoko.gudariwallet.util.formatFiat @@ -38,6 +39,7 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings fun HomeTab( vm: WalletViewModel, onHistoryClick: () -> Unit, + onNwcClick : () -> Unit, lyricist: Lyricist // ← NEW: passed in from wherever you call ProvideStrings ) { val strings = LocalAppStrings.current @@ -50,235 +52,252 @@ fun HomeTab( val isRefreshing = balanceState is BalanceState.Loading || (balanceState as? BalanceState.Success)?.isRefreshing == true - - PullToRefreshBox( - isRefreshing = isRefreshing, - onRefresh = { vm.refreshBalance() }, - modifier = Modifier.fillMaxSize() - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Box(modifier = Modifier.fillMaxSize()) { + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { vm.refreshBalance() }, + modifier = Modifier.fillMaxSize() ) { - when (val s = balanceState) { - - is BalanceState.Loading -> { - CircularProgressIndicator() - Spacer(Modifier.height(12.dp)) - Text( - text = strings.loadingBalance, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - is BalanceState.Success -> { - val selectedCurrency by vm.selectedCurrency.collectAsState() - var showCurrencyPicker by remember { mutableStateOf(false) } - var isLoadingCurrencies by remember { mutableStateOf(false) } - var currencyList by remember { mutableStateOf>(emptyList()) } - LaunchedEffect(showCurrencyPicker) { - if (showCurrencyPicker && currencyList.isEmpty()) { - isLoadingCurrencies = true - currencyList = vm.getSupportedCurrencies() - isLoadingCurrencies = false - } - } - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = if (balanceHidden) - Icons.Filled.VisibilityOff - else - Icons.Filled.Visibility, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp) - ) - - Spacer(Modifier.width(8.dp)) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + when (val s = balanceState) { + is BalanceState.Loading -> { + CircularProgressIndicator() + Spacer(Modifier.height(12.dp)) Text( - text = if (balanceHidden) "••••••" else formatSats(s.sats), - style = MaterialTheme.typography.displayLarge.copy( - fontFamily = FontFamily.Monospace - ), - color = if (balanceHidden) - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) - else - MaterialTheme.colorScheme.primary, - maxLines = 1, - softWrap = false, - modifier = Modifier - .widthIn(max = 280.dp) - .combinedClickable( - onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() }, - onClickLabel = if (balanceHidden) null else strings.hideBalance, - onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() }, - onLongClickLabel = if (balanceHidden) strings.revealBalance else null, - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) - ) - Spacer(Modifier.width(8.dp)) - Text( - text = strings.sats, - style = MaterialTheme.typography.titleMedium.copy( - fontFamily = FontFamily.Monospace - ), + text = strings.loadingBalance, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(Modifier.width(8.dp)) } - Spacer(Modifier.height(2.dp)) - val currency = selectedCurrency - if (currency != null) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - if (s.fiatAmount != null && s.fiatCurrency != null) { - Text( - text = if (balanceHidden) "••••" - else formatFiat(s.fiatAmount, s.fiatCurrency), - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = if (balanceHidden) - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) - else - MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), - maxLines = 1, - softWrap = false - ) - Spacer(Modifier.width(4.dp)) + + is BalanceState.Success -> { + val selectedCurrency by vm.selectedCurrency.collectAsState() + var showCurrencyPicker by remember { mutableStateOf(false) } + var isLoadingCurrencies by remember { mutableStateOf(false) } + var currencyList by remember { mutableStateOf>(emptyList()) } + LaunchedEffect(showCurrencyPicker) { + if (showCurrencyPicker && currencyList.isEmpty()) { + isLoadingCurrencies = true + currencyList = vm.getSupportedCurrencies() + isLoadingCurrencies = false } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clickable( - onClick = { showCurrencyPicker = true }, - onClickLabel = strings.changeCurrency, - indication = null, + } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (balanceHidden) + Icons.Filled.VisibilityOff + else + Icons.Filled.Visibility, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = if (balanceHidden) "••••••" else formatSats(s.sats), + style = MaterialTheme.typography.displayLarge.copy( + fontFamily = FontFamily.Monospace + ), + color = if (balanceHidden) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) + else + MaterialTheme.colorScheme.primary, + maxLines = 1, + softWrap = false, + modifier = Modifier + .widthIn(max = 280.dp) + .combinedClickable( + onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() }, + onClickLabel = if (balanceHidden) null else strings.hideBalance, + onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() }, + onLongClickLabel = if (balanceHidden) strings.revealBalance else null, + indication = null, interactionSource = remember { MutableInteractionSource() } ) - .padding(horizontal = 4.dp, vertical = 4.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = strings.sats, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + } + Spacer(Modifier.height(2.dp)) + val currency = selectedCurrency + if (currency != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (s.fiatAmount != null && s.fiatCurrency != null) { + Text( + text = if (balanceHidden) "••••" + else formatFiat(s.fiatAmount, s.fiatCurrency), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = if (balanceHidden) + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + maxLines = 1, + softWrap = false + ) + Spacer(Modifier.width(4.dp)) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + onClick = { showCurrencyPicker = true }, + onClickLabel = strings.changeCurrency, + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) + .padding(horizontal = 4.dp, vertical = 4.dp) + ) { + Text( + text = currency, + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.75f + ) + ) + Spacer(Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.ArrowDropDown, + contentDescription = strings.changeCurrency, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(14.dp) + ) + } + } + } else { + TextButton( + onClick = { showCurrencyPicker = true }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) ) { Text( - text = currency, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace - ), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f) - ) - Spacer(Modifier.width(2.dp)) - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = strings.changeCurrency, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.size(14.dp) + text = strings.showFiat, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) ) } } - } else { - TextButton( - onClick = { showCurrencyPicker = true }, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) - ) { + + Spacer(Modifier.height(8.dp)) + + if (!s.isRefreshing) { + val updatedLabel: String = remember(s.lastUpdated) { + formatLastUpdated(s.lastUpdated, strings) + } Text( - text = strings.showFiat, + text = strings.updatedAt(updatedLabel), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) - if (!s.isRefreshing) { - val updatedLabel: String = remember(s.lastUpdated) { - formatLastUpdated(s.lastUpdated, strings) + TextButton(onClick = onHistoryClick) { + Text( + text = strings.viewHistory, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) } - Text( - text = strings.updatedAt(updatedLabel), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Spacer(Modifier.height(16.dp)) - - TextButton(onClick = onHistoryClick) { - Text( - text = strings.viewHistory, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary - ) - } - - // ── NEW: Language switcher ───────────────────────────────── + // ── NEW: Language switcher ───────────────────────────────── // Spacer(Modifier.height(8.dp)) // LanguageSwitcherButton( // currentTag = lyricist.languageTag, // supportedTags = supportedTags, // onSelect = { tag -> lyricist.languageTag = tag } // ) - // ────────────────────────────────────────────────────────── + // ────────────────────────────────────────────────────────── - if (showCurrencyPicker) { - CurrencyPickerSheet( - currencies = currencyList, - selectedCurrency = selectedCurrency, - onSelect = { code -> - vm.setSelectedCurrency(code) - showCurrencyPicker = false - }, - isLoading = isLoadingCurrencies, - onDismiss = { showCurrencyPicker = false } - ) - } - } - - is BalanceState.Error -> { - if (s.staleSats != null) { - Text( - text = if (balanceHidden) "••••••" else formatSats(s.staleSats), - style = MaterialTheme.typography.displayLarge.copy( - fontFamily = FontFamily.Monospace - ), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - ) - Text( - text = strings.sats, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - ) - Spacer(Modifier.height(12.dp)) + if (showCurrencyPicker) { + CurrencyPickerSheet( + currencies = currencyList, + selectedCurrency = selectedCurrency, + onSelect = { code -> + vm.setSelectedCurrency(code) + showCurrencyPicker = false + }, + isLoading = isLoadingCurrencies, + onDismiss = { showCurrencyPicker = false } + ) + } } - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.small, - modifier = Modifier.fillMaxWidth(0.85f) - ) { - Text( - text = if (s.staleSats != null) - strings.couldNotRefresh - else - strings.couldNotLoadBalance, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.padding(12.dp) - ) + is BalanceState.Error -> { + if (s.staleSats != null) { + Text( + text = if (balanceHidden) "••••••" else formatSats(s.staleSats), + style = MaterialTheme.typography.displayLarge.copy( + fontFamily = FontFamily.Monospace + ), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + Text( + text = strings.sats, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + Spacer(Modifier.height(12.dp)) + } + + Surface( + color = MaterialTheme.colorScheme.errorContainer, + shape = MaterialTheme.shapes.small, + modifier = Modifier.fillMaxWidth(0.85f) + ) { + Text( + text = if (s.staleSats != null) + strings.couldNotRefresh + else + strings.couldNotLoadBalance, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp) + ) + } } } } } + // ── NWC button — top-right corner ───────────────────────────────── + IconButton( + onClick = onNwcClick, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.Filled.Cable, + contentDescription = "Wallet Connect", + tint = MaterialTheme.colorScheme.onSurfaceVariant + .copy(alpha = 0.7f) + ) + } } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index 60498ab..3550b0a 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -62,6 +62,8 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel +import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen +import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendStrings @@ -81,6 +83,7 @@ private const val ROUTE_HOME = "home" private const val ROUTE_SCANNER = "scanner" private const val ROUTE_HISTORY = "history" private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}" +private const val ROUTE_NWC = "nwc" @Composable fun WalletScreen( @@ -120,6 +123,9 @@ fun WalletScreen( ) } val historyVm: HistoryViewModel = viewModel(factory = historyFactory) + val nwcVm: NwcViewModel = viewModel( + factory = NwcViewModel.Factory(repo = vm.repo) // same pattern as historyVm + ) val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route @@ -127,10 +133,12 @@ fun WalletScreen( val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL && currentRoute != ROUTE_SCANNER && currentRoute != ROUTE_HISTORY + && currentRoute != ROUTE_NWC val isFullScreenDestination = currentRoute == ROUTE_HISTORY || currentRoute == ROUTE_PAYMENT_DETAIL || currentRoute == ROUTE_SCANNER + || currentRoute == ROUTE_NWC // ── Notification tap ────────────────────────────────────────────────────── val hash = pendingPaymentHash.value @@ -322,6 +330,11 @@ fun WalletScreen( launchSingleTop = true } }, + onNwcClick = { + navController.navigate(ROUTE_NWC) { + launchSingleTop = true + } + }, lyricist = lyricist ) } @@ -415,6 +428,18 @@ fun WalletScreen( } } } + composable( + ROUTE_NWC, + enterTransition = { fadeIn(animationSpec = tween(150)) }, + popExitTransition = { fadeOut(animationSpec = tween(150)) } + ) { + NwcScreen( + viewModel = nwcVm, + onBack = { + navController.popBackStack(ROUTE_HOME, inclusive = false) + } + ) + } } // ── Clipboard banner — floats over content, slides in from top ──── diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt new file mode 100644 index 0000000..abba8aa --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcScreen.kt @@ -0,0 +1,559 @@ +package com.bitcointxoko.gudariwallet.ui.nwc + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.* +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitcointxoko.gudariwallet.api.NwcBudget +import com.bitcointxoko.gudariwallet.api.NwcGetResponse +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +// ── Available permissions ───────────────────────────────────────────────────── +private data class NwcPermission( + val serverKey : String, // what we send in the permissions array + val displayLabel : String, // shown in the UI + val default : Boolean // pre-checked by default +) +private val ALL_PERMISSIONS = listOf( + NwcPermission("pay", "Send payments", default = true), + NwcPermission("invoice", "Create invoices", default = true), + NwcPermission("lookup", "Lookup invoice status", default = true), + NwcPermission("history", "Read transaction history", default = true), + NwcPermission("balance", "Read wallet balance", default = true), + NwcPermission("info", "Read account info", default = true) +) + +// ── Screen ──────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NwcScreen( + viewModel : NwcViewModel, + onBack : () -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle() + val isCreating by viewModel.isCreating.collectAsStateWithLifecycle() + val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle() + + var showAddSheet by remember { mutableStateOf(false) } + var pendingDeletePubkey by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Wallet Connect") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showAddSheet = true }) { + Icon(Icons.Default.Add, contentDescription = "Add connection") + } + } + ) { innerPadding -> + + val isRefreshing = uiState is NwcUiState.Loading + + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { viewModel.loadConnections() }, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when (val state = uiState) { + is NwcUiState.Loading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + is NwcUiState.Error -> { + NwcErrorContent( + message = state.message, + onRetry = { viewModel.loadConnections() }, + modifier = Modifier.fillMaxSize() + ) + } + + is NwcUiState.Success -> { + if (state.connections.isEmpty()) { + NwcEmptyContent( + onAddNew = { showAddSheet = true }, + modifier = Modifier.fillMaxSize() + ) + } else { + LazyColumn( + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items( + items = state.connections, + key = { it.data.pubkey } + ) { connection -> + NwcConnectionCard( + connection = connection, + isDeleting = isDeleting, + onDeleteClick = { pendingDeletePubkey = connection.data.pubkey } + ) + } + } + } + } + } + } + } + + // ── Add connection sheet ─────────────────────────────────────────────────── + if (showAddSheet) { + NwcAddConnectionSheet( + isCreating = isCreating, + onCreate = { description, permissions, expiresAt -> + viewModel.createConnection( + description = description, + permissions = permissions, + expiresAt = expiresAt + ) + showAddSheet = false + }, + onDismiss = { showAddSheet = false } + ) + } + + // ── Pairing URL dialog ───────────────────────────────────────────────────── + pairingUrl?.let { url -> + NwcPairingUrlDialog( + url = url, + onDismiss = { viewModel.clearPairingUrl() } + ) + } + + // ── Delete confirmation dialog ───────────────────────────────────────────── + pendingDeletePubkey?.let { pubkey -> + AlertDialog( + onDismissRequest = { pendingDeletePubkey = null }, + title = { Text("Remove connection?") }, + text = { Text("This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.") }, + confirmButton = { + TextButton( + onClick = { + viewModel.deleteConnection(pubkey) + pendingDeletePubkey = null + } + ) { Text("Remove", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { pendingDeletePubkey = null }) { Text("Cancel") } + } + ) + } +} + +// ── Add connection bottom sheet ─────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NwcAddConnectionSheet( + isCreating : Boolean, + onCreate : (description: String, permissions: List, expiresAt: Long) -> Unit, + onDismiss : () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var description by remember { mutableStateOf("") } + var selectedPermissions by remember { + mutableStateOf(ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet()) + } + var neverExpires by remember { mutableStateOf(true) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + Text( + text = "New connection", + style = MaterialTheme.typography.titleMedium + ) + + // ── Description ─────────────────────────────────────────────── + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Label (e.g. Zeus, Alby)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + // ── Permissions ─────────────────────────────────────────────── + Text( + text = "Permissions", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + ALL_PERMISSIONS.forEach { perm -> + val checked = perm.serverKey in selectedPermissions + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = { on -> + selectedPermissions = if (on) + selectedPermissions + perm.serverKey + else + selectedPermissions - perm.serverKey + } + ) + Spacer(Modifier.width(8.dp)) + Text( + text = perm.displayLabel, + style = MaterialTheme.typography.bodyMedium + ) + } + } + + + // ── Expiry ──────────────────────────────────────────────────── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = neverExpires, + onCheckedChange = { neverExpires = it } + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Never expires", + style = MaterialTheme.typography.bodyMedium + ) + } + + // ── Create button ───────────────────────────────────────────── + Button( + onClick = { + onCreate( + description.trim().ifBlank { "Unnamed" }, + selectedPermissions.toList(), + if (neverExpires) 0L else 0L // expiry picker can be added later + ) + }, + enabled = selectedPermissions.isNotEmpty() && !isCreating, + modifier = Modifier.fillMaxWidth() + ) { + if (isCreating) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(Modifier.width(8.dp)) + } + Text(if (isCreating) "Creating…" else "Create connection") + } + } + } +} + +// ── Pairing URL dialog ──────────────────────────────────────────────────────── + +@Composable +private fun NwcPairingUrlDialog( + url : String, + onDismiss : () -> Unit +) { + val clipboard = LocalClipboardManager.current + var copied by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Connection created") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Copy this connection string into your app. It will not be shown again.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) { + Text( + text = url, + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace + ), + modifier = Modifier.padding(12.dp), + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + AnimatedVisibility(visible = copied) { + Text( + text = "✓ Copied to clipboard", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + }, + confirmButton = { + Button( + onClick = { + clipboard.setText(AnnotatedString(url)) + copied = true + } + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text("Copy") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Done") } + } + ) +} + +// ── Connection Card ─────────────────────────────────────────────────────────── + +@Composable +private fun NwcConnectionCard( + connection : NwcGetResponse, + isDeleting : Boolean, + onDeleteClick: () -> Unit, + modifier : Modifier = Modifier +) { + val key = connection.data + val budgets = connection.budgets + val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (expired) + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else + MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = key.description.ifBlank { "Unnamed connection" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (expired) { + Text( + text = "Expired", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + IconButton(onClick = onDeleteClick, enabled = !isDeleting) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete connection", + tint = MaterialTheme.colorScheme.error + ) + } + } + + Text( + text = key.pubkey.take(16) + "…" + key.pubkey.takeLast(8), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + HorizontalDivider() + + if (key.permissions.isNotBlank()) { + NwcPermissionChips(permissions = key.permissions) + } + + NwcMetaRow( + label = "Expires", + value = if (key.expires_at <= 0L) "Never" else formatEpoch(key.expires_at) + ) + NwcMetaRow( + label = "Last used", + value = if (key.last_used <= 0L) "Never" else formatEpoch(key.last_used) + ) + + if (budgets.isNotEmpty()) { + HorizontalDivider() + budgets.forEach { NwcBudgetRow(budget = it) } + } + } + } +} + +// ── Permission chips ────────────────────────────────────────────────────────── + +@Composable +private fun NwcPermissionChips(permissions: String, modifier: Modifier = Modifier) { + val perms = remember(permissions) { + permissions.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + perms.forEach { perm -> + SuggestionChip( + onClick = {}, + label = { + Text( + text = perm.replace("_", " "), + style = MaterialTheme.typography.labelSmall + ) + } + ) + } + } +} + +// ── Budget row ──────────────────────────────────────────────────────────────── + +@Composable +private fun NwcBudgetRow(budget: NwcBudget, modifier: Modifier = Modifier) { + val totalSats = budget.budget_msats / 1_000L + val usedSats = budget.used_budget_msats / 1_000L + val progress = if (totalSats > 0) (usedSats.toFloat() / totalSats.toFloat()).coerceIn(0f, 1f) else 0f + val windowHours = budget.refresh_window / 3600 + + Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("Budget (${windowHours}h window)", style = MaterialTheme.typography.labelMedium) + Text( + "$usedSats / $totalSats sats", + style = MaterialTheme.typography.labelMedium, + color = if (progress >= 1f) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth().height(6.dp), + color = if (progress >= 1f) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary + ) + } +} + +// ── Meta row ────────────────────────────────────────────────────────────────── + +@Composable +private fun NwcMetaRow(label: String, value: String, modifier: Modifier = Modifier) { + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodySmall) + } +} + +// ── Empty / Error states ────────────────────────────────────────────────────── + +@Composable +private fun NwcEmptyContent(onAddNew: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("No connections yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Text( + "Tap + to connect an app via Nostr Wallet Connect.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(24.dp)) + Button(onClick = onAddNew) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Add connection") + } + } +} + +@Composable +private fun NwcErrorContent(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("Something went wrong", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(24.dp)) + OutlinedButton(onClick = onRetry) { Text("Retry") } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +private val dateFormatter: DateTimeFormatter = + DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT) + .withZone(ZoneId.systemDefault()) + +private fun formatEpoch(epochSeconds: Long): String = + runCatching { dateFormatter.format(Instant.ofEpochSecond(epochSeconds)) }.getOrDefault("—") diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt new file mode 100644 index 0000000..d48fd47 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nwc/NwcViewModel.kt @@ -0,0 +1,175 @@ +package com.bitcointxoko.gudariwallet.ui.nwc + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.bitcointxoko.gudariwallet.api.NwcGetResponse +import com.bitcointxoko.gudariwallet.api.NwcNewBudget +import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest +import com.bitcointxoko.gudariwallet.data.WalletRepository +import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator +import com.bitcointxoko.gudariwallet.util.NwcKeypair +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +private const val TAG = "NwcViewModel" + +// ── UI State ────────────────────────────────────────────────────────────────── + +sealed interface NwcUiState { + data object Loading : NwcUiState + data class Success(val connections: List) : NwcUiState + data class Error(val message: String) : NwcUiState +} + +// ── ViewModel ───────────────────────────────────────────────────────────────── + +class NwcViewModel( + private val repo: WalletRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(NwcUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + /** Whether a delete operation is currently in flight (disables buttons). */ + private val _isDeleting = MutableStateFlow(false) + val isDeleting: StateFlow = _isDeleting.asStateFlow() + + // Holds the keypair only while the "add connection" flow is active. + // Cleared as soon as the user dismisses the QR screen. + private val _pendingKeypair = MutableStateFlow(null) + val pendingKeypair: StateFlow = _pendingKeypair.asStateFlow() + + /** True while a createConnection call is in flight. */ + private val _isCreating = MutableStateFlow(false) + val isCreating: StateFlow = _isCreating.asStateFlow() + + /** Holds the pairing URL after a successful registration, until dismissed. */ + private val _pairingUrl = MutableStateFlow(null) + val pairingUrl: StateFlow = _pairingUrl.asStateFlow() + + init { + loadConnections() + } + + // ── Load ────────────────────────────────────────────────────────────────── + + fun loadConnections(includeExpired: Boolean = false) { + Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)") + _uiState.value = NwcUiState.Loading + viewModelScope.launch { + runCatching { repo.getNwcKeys(includeExpired) } + .onSuccess { keys -> + Timber.i("[$TAG] loaded ${keys.size} NWC connection(s)") + _uiState.value = NwcUiState.Success(keys) + } + .onFailure { e -> + Timber.e(e, "[$TAG] failed to load NWC connections") + _uiState.value = NwcUiState.Error( + e.message ?: "Failed to load connections" + ) + } + } + } + + /** + * Generates a keypair, registers it with the server, then fetches + * the nostr+walletconnect:// pairing URL. + * Private key is held only in local scope — never stored. + */ + fun createConnection( + description : String, + permissions : List, + expiresAt : Long = 0L, + budgets : List = emptyList() + ) { + Timber.d("[$TAG] createConnection description='$description' permissions=$permissions") + _isCreating.value = true + viewModelScope.launch { + runCatching { + val keypair = NwcKeyGenerator.generate() + Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}…") + check(keypair.pubKeyHex.length == 64) { + "pubKeyHex must be 64 hex chars, got ${keypair.pubKeyHex.length}" + } + check(keypair.privKeyHex.length == 64) { + "privKeyHex must be 64 hex chars, got ${keypair.privKeyHex.length}" + } + Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}… (${keypair.pubKeyHex.length} chars)") + Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}") + Timber.d("[$TAG] PAIRING secret=${keypair.privKeyHex}") + + repo.registerNwcKey( + pubkey = keypair.pubKeyHex, + request = NwcRegistrationRequest( + description = description, + permissions = permissions, + expires_at = expiresAt, + budgets = budgets + ) + ) + Timber.i("[$TAG] registered key ✓") + + val url = repo.getNwcPairingUrl(keypair.privKeyHex) + Timber.i("[$TAG] pairing URL fetched ✓") + Timber.d("[$TAG] URI=$url") + url + } + .onSuccess { url -> + _pairingUrl.value = url + loadConnections() + } + .onFailure { e -> + Timber.e(e, "[$TAG] createConnection failed") + _uiState.value = NwcUiState.Error(e.message ?: "Failed to create connection") + } + .also { _isCreating.value = false } + } + } + + /** Wipes the pairing URL from memory once the user has copied it. */ + fun clearPairingUrl() { + Timber.d("[$TAG] clearing pairing URL from memory") + _pairingUrl.value = null + } + + // ── Delete ──────────────────────────────────────────────────────────────── + + fun deleteConnection(pubkey: String) { + Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}…") + _isDeleting.value = true + viewModelScope.launch { + runCatching { repo.deleteNwcKey(pubkey) } + .onSuccess { + Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓") + // Remove locally for instant feedback, then reload to sync + val current = _uiState.value + if (current is NwcUiState.Success) { + _uiState.value = NwcUiState.Success( + current.connections.filter { it.data.pubkey != pubkey } + ) + } + loadConnections() + } + .onFailure { e -> + Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}…") + _uiState.value = NwcUiState.Error( + e.message ?: "Failed to delete connection" + ) + } + .also { _isDeleting.value = false } + } + } + + // ── Factory ─────────────────────────────────────────────────────────────── + + class Factory(private val repo: WalletRepository) : + ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + NwcViewModel(repo) as T + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/NwcKeyGenerator.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NwcKeyGenerator.kt new file mode 100644 index 0000000..dde0d90 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/NwcKeyGenerator.kt @@ -0,0 +1,69 @@ +package com.bitcointxoko.gudariwallet.util + +import fr.acinq.secp256k1.Secp256k1 +import java.security.SecureRandom + +data class NwcKeypair( + val privKeyHex: String, // 32-byte hex — the "secret" in the pairing URL + val pubKeyHex : String // 32-byte BIP340 x-only hex — the {pubkey} path param +) + +object NwcKeyGenerator { + + private val rng = SecureRandom() + + fun generate(): NwcKeypair { + val secp256k1 = Secp256k1.get() + + val privKey = ByteArray(32) + do { rng.nextBytes(privKey) } while (!secp256k1.secKeyVerify(privKey)) + + // BIP340: if the derived pubkey has odd Y, negate the private key. + // The connecting app (Damus, Zeus, etc.) will do the same negation + // before signing — so we must register the pubkey of the (possibly negated) key. + val bip340PrivKey = normalizeToBip340(secp256k1, privKey) + + val pubKeyInternal = secp256k1.pubkeyCreate(bip340PrivKey) + val pubKeyCompressed = secp256k1.pubKeyCompress(pubKeyInternal) // 33 bytes: 02||x or 03||x + + // After BIP340 normalization the prefix is always 02 (even Y). + // x-only = drop the prefix byte. + val xOnlyPubKey = pubKeyCompressed.copyOfRange(1, 33) + + return NwcKeypair( + privKeyHex = bip340PrivKey.toHex(), // ← send the (possibly negated) privkey as secret + pubKeyHex = xOnlyPubKey.toHex() + ) + } + + /** + * BIP340 normalization: if the public key derived from [privKey] has an odd Y coordinate + * (compressed prefix 0x03), negate the private key so that the resulting pubkey has even Y. + * This ensures the secret we hand to the connecting app and the pubkey we register + * are consistent with how BIP340/Schnorr signing works. + */ + private fun normalizeToBip340(secp256k1: Secp256k1, privKey: ByteArray): ByteArray { + val pubKeyInternal = secp256k1.pubkeyCreate(privKey) + val pubKeyCompressed = secp256k1.pubKeyCompress(pubKeyInternal) + val hasOddY = pubKeyCompressed[0] == 0x03.toByte() + + return if (hasOddY) { + // Negate: n - privKey (secp256k1 group order) + val n = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141" + val negated = (java.math.BigInteger(n, 16) - java.math.BigInteger(1, privKey)) + .toByteArray() + .let { bytes -> + // BigInteger.toByteArray() may prepend a 0x00 sign byte — strip it and pad to 32 + val stripped = if (bytes[0] == 0x00.toByte()) bytes.copyOfRange(1, bytes.size) else bytes + ByteArray(32 - stripped.size) + stripped // left-pad with zeros if needed + } + negated + } else { + privKey + } + } + + private fun ByteArray.toHex(): String = + joinToString("") { "%02x".format(it) } +} +