diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 073b3417..bdd283e7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -134,7 +134,7 @@ dependencies { implementation("com.google.zxing:core:3.5.3") // CDK Kotlin bindings - implementation("org.cashudevkit:cdk-kotlin:0.15.1") + implementation("org.cashudevkit:cdk-kotlin:0.15.2-rc.0") // ML Kit Barcode Scanning implementation("com.google.mlkit:barcode-scanning:17.3.0") diff --git a/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt b/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt index f0b352af..3ba37754 100644 --- a/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt @@ -52,18 +52,28 @@ import kotlinx.coroutines.withContext class PaymentRequestActivity : AppCompatActivity() { + private lateinit var unifiedQrImageView: ImageView private lateinit var cashuQrImageView: ImageView private lateinit var lightningQrImageView: ImageView + private lateinit var unifiedQrContainer: View private lateinit var cashuQrContainer: View private lateinit var lightningQrContainer: View - private lateinit var cashuTab: TextView - private lateinit var lightningTab: TextView + private lateinit var unifiedTab: android.widget.LinearLayout + private lateinit var cashuTab: android.widget.LinearLayout + private lateinit var lightningTab: android.widget.LinearLayout + private lateinit var unifiedTabText: TextView + private lateinit var cashuTabText: TextView + private lateinit var lightningTabText: TextView + private lateinit var tabContainer: android.widget.FrameLayout + private lateinit var tabIndicator: View private lateinit var largeAmountDisplay: TextView private lateinit var convertedAmountDisplay: TextView private lateinit var statusText: TextView private lateinit var closeButton: View private lateinit var shareButton: View private lateinit var lightningLoadingSpinner: View + private lateinit var unifiedLoadingSpinner: View + private lateinit var cashuLoadingSpinner: View private lateinit var lightningLogoCard: View // NFC Animation views @@ -78,13 +88,14 @@ class PaymentRequestActivity : AppCompatActivity() { // Tip-related views private lateinit var tipInfoText: TextView - // HCE mode for deciding which payload to emulate (Cashu vs Lightning) - private enum class HceMode { CASHU, LIGHTNING } + // HCE mode for deciding which payload to emulate + private enum class HceMode { UNIFIED, CASHU, LIGHTNING } private enum class OverlayActionMode { SUCCESS, ERROR } private var paymentAmount: Long = 0 private var bitcoinPriceWorker: BitcoinPriceWorker? = null private var hcePaymentRequest: String? = null + private var hcePaymentRequestBech32: String? = null private var formattedAmountString: String = "" // Tip state (received from TipSelectionActivity) @@ -93,10 +104,10 @@ class PaymentRequestActivity : AppCompatActivity() { private var baseAmountSats: Long = 0 private var baseFormattedAmount: String = "" - // Current HCE mode (defaults to Cashu) - private var currentHceMode: HceMode = HceMode.CASHU + // Current HCE mode (defaults to Unified) + private var currentHceMode: HceMode = HceMode.UNIFIED - // Tab manager for Cashu/Lightning tab switching + // Tab manager for Unified/Cashu/Lightning tab switching private lateinit var tabManager: PaymentTabManager // Payment handlers @@ -152,18 +163,28 @@ class PaymentRequestActivity : AppCompatActivity() { setContentView(R.layout.activity_payment_request) // Initialize views + unifiedQrImageView = findViewById(R.id.unified_qr) cashuQrImageView = findViewById(R.id.payment_request_qr) lightningQrImageView = findViewById(R.id.lightning_qr) + unifiedQrContainer = findViewById(R.id.unified_qr_container) cashuQrContainer = findViewById(R.id.cashu_qr_container) lightningQrContainer = findViewById(R.id.lightning_qr_container) + unifiedTab = findViewById(R.id.unified_tab) cashuTab = findViewById(R.id.cashu_tab) lightningTab = findViewById(R.id.lightning_tab) + unifiedTabText = findViewById(R.id.unified_tab_text) + cashuTabText = findViewById(R.id.cashu_tab_text) + lightningTabText = findViewById(R.id.lightning_tab_text) + tabContainer = findViewById(R.id.tab_container) + tabIndicator = findViewById(R.id.tab_indicator) largeAmountDisplay = findViewById(R.id.large_amount_display) convertedAmountDisplay = findViewById(R.id.converted_amount_display) statusText = findViewById(R.id.payment_status_text) closeButton = findViewById(R.id.close_button) shareButton = findViewById(R.id.share_button) lightningLoadingSpinner = findViewById(R.id.lightning_loading_spinner) + unifiedLoadingSpinner = findViewById(R.id.unified_loading_spinner) + cashuLoadingSpinner = findViewById(R.id.cashu_loading_spinner) lightningLogoCard = findViewById(R.id.lightning_logo_card) // NFC Animation views @@ -181,10 +202,21 @@ class PaymentRequestActivity : AppCompatActivity() { // Initialize tab manager tabManager = PaymentTabManager( + tabContainer = tabContainer, + tabIndicator = tabIndicator, + unifiedTab = unifiedTab, cashuTab = cashuTab, lightningTab = lightningTab, + unifiedTabText = unifiedTabText, + cashuTabText = cashuTabText, + lightningTabText = lightningTabText, + unifiedQrContainer = unifiedQrContainer, cashuQrContainer = cashuQrContainer, lightningQrContainer = lightningQrContainer, + unifiedQrImageView = unifiedQrImageView, + unifiedLoadingSpinner = unifiedLoadingSpinner, + lightningLoadingSpinner = lightningLoadingSpinner, + cashuLoadingSpinner = cashuLoadingSpinner, cashuQrImageView = cashuQrImageView, lightningQrImageView = lightningQrImageView, resources = resources, @@ -193,25 +225,28 @@ class PaymentRequestActivity : AppCompatActivity() { // Set up tabs with listener tabManager.setup(object : PaymentTabManager.TabSelectionListener { - override fun onLightningTabSelected() { - Log.d(TAG, "onLightningTabSelected() called. lightningStarted=$lightningStarted, lightningInvoice=$lightningInvoice") - - // If invoice is delayed (dev setting), start it now if not already started - if (!lightningStarted && DeveloperPrefs.isLightningInvoiceDelayed(this@PaymentRequestActivity)) { - startLightningMintFlow() - } - - // If invoice is already known, try to switch HCE now - if (lightningInvoice != null) { - setHceToLightning() + override fun onTabSelected(tab: PaymentTabManager.PaymentTab) { + Log.d(TAG, "onTabSelected() called. tab=$tab, lightningStarted=$lightningStarted, lightningInvoice=$lightningInvoice") + when (tab) { + PaymentTabManager.PaymentTab.UNIFIED -> { + if (!lightningStarted && DeveloperPrefs.isLightningInvoiceDelayed(this@PaymentRequestActivity)) { + startLightningMintFlow() + } + setHceToUnified() + } + PaymentTabManager.PaymentTab.LIGHTNING -> { + if (!lightningStarted && DeveloperPrefs.isLightningInvoiceDelayed(this@PaymentRequestActivity)) { + startLightningMintFlow() + } + if (lightningInvoice != null) { + setHceToLightning() + } + } + PaymentTabManager.PaymentTab.CASHU -> { + setHceToCashu() + } } } - - override fun onCashuTabSelected() { - Log.d(TAG, "onCashuTabSelected() called. currentHceMode=$currentHceMode") - // When user returns to Cashu tab, restore Cashu HCE payload - setHceToCashu() - } }) // Initialize Bitcoin price worker @@ -267,10 +302,17 @@ class PaymentRequestActivity : AppCompatActivity() { } shareButton.setOnClickListener { - val toShare = if (tabManager.isLightningTabSelected()) { - lightningHandler?.currentInvoice ?: lightningInvoice - } else { - nostrHandler?.paymentRequest ?: lightningHandler?.currentInvoice ?: lightningInvoice + val currentTab = tabManager.getCurrentTab() + val toShare = when (currentTab) { + PaymentTabManager.PaymentTab.LIGHTNING -> lightningHandler?.currentInvoice ?: lightningInvoice + PaymentTabManager.PaymentTab.CASHU -> nostrHandler?.paymentRequest + PaymentTabManager.PaymentTab.UNIFIED -> { + val creq = nostrHandler?.paymentRequestBech32 + val lnbc = lightningHandler?.currentInvoice ?: lightningInvoice + if (creq != null && lnbc != null) { + "bitcoin:?creq=${creq}&lightning=${lnbc}" + } else creq ?: lnbc + } } if (toShare != null) { sharePaymentRequest(toShare) @@ -290,11 +332,6 @@ class PaymentRequestActivity : AppCompatActivity() { // Initialize all payment modes (NDEF, Nostr, Lightning) initializePaymentRequest() - - // If resuming and we have Lightning data, auto-switch to Lightning tab - if (isResumingPayment && resumeLightningQuoteId != null) { - tabManager.selectLightningTab() - } } /** @@ -469,7 +506,7 @@ class PaymentRequestActivity : AppCompatActivity() { // Initialize Lightning handler with preferred mint (will be started when tab is selected) val preferredLightningMint = mintManager.getPreferredLightningMint() - lightningHandler = LightningMintHandler(preferredLightningMint, allowedMints, uiScope) + lightningHandler = LightningMintHandler(this, preferredLightningMint, allowedMints, uiScope) // Unless developer setting to delay it is enabled, start it immediately if (!DeveloperPrefs.isLightningInvoiceDelayed(this)) { @@ -490,11 +527,13 @@ class PaymentRequestActivity : AppCompatActivity() { val mintsForPaymentRequest = if (mintManager.isSwapFromUnknownMintsEnabled()) null else allowedMints - hcePaymentRequest = CashuPaymentHelper.createPaymentRequest( + val generatedHce = CashuPaymentHelper.createPaymentRequest( paymentAmount, - "Payment of $paymentAmount sats", + getString(R.string.payment_request_default_description, paymentAmount), mintsForPaymentRequest ) + hcePaymentRequest = generatedHce?.original + hcePaymentRequestBech32 = generatedHce?.bech32 if (hcePaymentRequest == null) { Log.e(TAG, "Failed to create payment request for HCE") @@ -558,6 +597,79 @@ class PaymentRequestActivity : AppCompatActivity() { } } + private fun setHceToUnified() { + val creq = hcePaymentRequestBech32 + val lnbc = lightningInvoice + + if (creq == null && lnbc == null) { + Log.w(TAG, "setHceToUnified() called but both creq and lnbc are null") + return + } + + val payload = if (creq != null && lnbc != null) { + "bitcoin:?creq=${creq}&lightning=${lnbc}" + } else creq ?: lnbc + + try { + val hceService = NdefHostCardEmulationService.getInstance() + if (hceService != null) { + Log.d(TAG, "setHceToUnified(): Switching HCE payload to Unified. payload=$payload") + hceService.setPaymentRequest(payload ?: "", paymentAmount) + currentHceMode = HceMode.UNIFIED + } else { + Log.w(TAG, "setHceToUnified(): HCE service not available") + } + } catch (e: Exception) { + Log.e(TAG, "setHceToUnified(): Error while setting HCE Unified payload: ${e.message}", e) + } + } + + private fun updateUnifiedQrCode() { + val creq = nostrHandler?.paymentRequestBech32 + val lnbc = lightningInvoice + + // We only show the unified QR when BOTH Cashu and Lightning requests are ready + // (unless lightning is explicitly disabled or errored out, but for simplicity we assume we need both if Lightning is supported) + + // Since we attempt to fetch lightning invoice by default if allowed mints are set, + // we'll wait for both unless lnbc fails (which we handle below). + // Let's implement the logic: If we have creq, we still want to wait for lnbc if lightning was started. + + // Actually, if we don't have creq yet, definitely wait. + if (creq == null) return + + // If Lightning is enabled (lightningStarted is true) and we don't have lnbc yet, wait. + // If lnbc is null because of an error, it stays null, but we don't want to spin forever. + // Wait, if there's an error, onError hides the lightning spinner. But does it hide the unified spinner? + // We should just hide the unified spinner and show creq if lnbc fails, or we should never show it? + // For simplicity: if lightningStarted == true and lightningInvoice == null, we wait. BUT wait, how do we know if it errored? + // Let's just track if lightning is "in progress". For now, we will require both. If one fails, the user is notified. + + if (lightningStarted && lnbc == null) { + // Still waiting for lightning invoice + return + } + + val unifiedUri = if (creq != null && lnbc != null) { + "bitcoin:?creq=${creq}&lightning=${lnbc}" + } else creq ?: lnbc + + if (unifiedUri == null) return + + try { + val qrBitmap = QrCodeGenerator.generate(unifiedUri, 512) + unifiedQrImageView.setImageBitmap(qrBitmap) + unifiedQrImageView.visibility = View.VISIBLE + unifiedLoadingSpinner.visibility = View.GONE + + if (tabManager.getCurrentTab() == PaymentTabManager.PaymentTab.UNIFIED) { + setHceToUnified() + } + } catch (e: Exception) { + Log.e(TAG, "Error generating Unified QR bitmap: ${e.message}", e) + } + } + private fun startNostrPaymentFlow() { val handler = nostrHandler ?: return @@ -566,7 +678,10 @@ class PaymentRequestActivity : AppCompatActivity() { try { val qrBitmap = QrCodeGenerator.generate(paymentRequest, 512) cashuQrImageView.setImageBitmap(qrBitmap) + cashuQrImageView.visibility = View.VISIBLE + cashuLoadingSpinner.visibility = View.GONE statusText.text = getString(R.string.payment_request_status_waiting_for_payment) + updateUnifiedQrCode() } catch (e: Exception) { Log.e(TAG, "Error generating Cashu QR bitmap: ${e.message}", e) statusText.text = getString(R.string.payment_request_status_error_qr) @@ -642,9 +757,11 @@ class PaymentRequestActivity : AppCompatActivity() { try { val qrBitmap = QrCodeGenerator.generate(bolt11, 512) lightningQrImageView.setImageBitmap(qrBitmap) + lightningQrImageView.visibility = View.VISIBLE // Hide loading spinner and show the bolt icon lightningLoadingSpinner.visibility = View.GONE lightningLogoCard.visibility = View.VISIBLE + updateUnifiedQrCode() } catch (e: Exception) { Log.e(TAG, "Error generating Lightning QR bitmap: ${e.message}", e) // Still hide spinner on error @@ -652,7 +769,7 @@ class PaymentRequestActivity : AppCompatActivity() { } // If Lightning tab is currently visible, switch HCE payload to Lightning - if (tabManager.isLightningTabSelected()) { + if (tabManager.getCurrentTab() == PaymentTabManager.PaymentTab.LIGHTNING) { Log.d(TAG, "onInvoiceReady(): Lightning tab is selected, calling setHceToLightning()") setHceToLightning() } @@ -663,15 +780,16 @@ class PaymentRequestActivity : AppCompatActivity() { } override fun onError(message: String) { + // Hide loading spinner on error + lightningLoadingSpinner.visibility = View.GONE + lightningStarted = false // Mark as finished/failed so unified QR can proceed with just Cashu + updateUnifiedQrCode() + // Do not immediately fail the whole payment; NFC or Nostr may still succeed. - // Only surface a toast if Lightning tab is currently active. - if (tabManager.isLightningTabSelected()) { - Toast.makeText( - this@PaymentRequestActivity, - getString(R.string.payment_request_lightning_error_failed, message), - Toast.LENGTH_LONG - ).show() - } + // Surface a toast to inform the user that the Unified QR will only contain Cashu, + // or that the Lightning tab is unavailable. + val errorMsg = getString(R.string.payment_request_lightning_error_failed, message) + Toast.makeText(this@PaymentRequestActivity, errorMsg, Toast.LENGTH_LONG).show() } } } @@ -686,10 +804,20 @@ class PaymentRequestActivity : AppCompatActivity() { Log.d(TAG, "Setting up NDEF payment with HCE service") // Set the payment request to the HCE service based on current tab selection - if (tabManager.isLightningTabSelected() && lightningInvoice != null) { - setHceToLightning() - } else { - setHceToCashu() + when (tabManager.getCurrentTab()) { + PaymentTabManager.PaymentTab.LIGHTNING -> { + if (lightningInvoice != null) { + setHceToLightning() + } else { + setHceToCashu() + } + } + PaymentTabManager.PaymentTab.UNIFIED -> { + setHceToUnified() + } + PaymentTabManager.PaymentTab.CASHU -> { + setHceToCashu() + } } // Set up callback for when a token is received or an error occurs diff --git a/app/src/main/java/com/electricdreams/numo/core/cashu/CashuWalletManager.kt b/app/src/main/java/com/electricdreams/numo/core/cashu/CashuWalletManager.kt index 48285892..e59419a6 100644 --- a/app/src/main/java/com/electricdreams/numo/core/cashu/CashuWalletManager.kt +++ b/app/src/main/java/com/electricdreams/numo/core/cashu/CashuWalletManager.kt @@ -16,6 +16,9 @@ import org.cashudevkit.WalletDatabaseImpl import org.cashudevkit.NoPointer import org.cashudevkit.WalletSqliteDatabase import org.cashudevkit.WalletRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import org.cashudevkit.generateMnemonic /** @@ -42,6 +45,9 @@ object CashuWalletManager : MintManager.MintChangeListener { @Volatile private var wallet: WalletRepository? = null + private val _isWalletReady = MutableStateFlow(false) + val isWalletReady: StateFlow = _isWalletReady.asStateFlow() + /** Initialize from ModernPOSActivity. Safe to call multiple times. */ fun init(context: Context) { if (this::appContext.isInitialized) return @@ -159,6 +165,7 @@ object CashuWalletManager : MintManager.MintChangeListener { database = db wallet = newWallet + _isWalletReady.value = true Log.d(TAG, "Wallet restore complete. Restored ${mints.size} mints.") return balanceChanges @@ -437,10 +444,12 @@ object CashuWalletManager : MintManager.MintChangeListener { database = db wallet = newWallet + _isWalletReady.value = true Log.d(TAG, "Initialized WalletRepository with ${'$'}{mints.size} mints; DB=${'$'}{dbFile.absolutePath}") } catch (t: Throwable) { Log.e(TAG, "Failed to initialize WalletRepository", t) + _isWalletReady.value = false } } diff --git a/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt b/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt index 7e6ea268..f7b32118 100644 --- a/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt +++ b/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt @@ -37,6 +37,11 @@ object CashuPaymentHelper { * Structured result of validating a Cashu token against an expected * amount and the merchant's allowed mint list. */ + data class GeneratedPaymentRequest( + val original: String, + val bech32: String + ) + sealed class TokenValidationResult { /** The provided string was not a valid Cashu token. */ object InvalidFormat : TokenValidationResult() @@ -68,7 +73,7 @@ object CashuPaymentHelper { amount: Long, description: String?, allowedMints: List?, - ): String? { + ): GeneratedPaymentRequest? { return try { val paymentRequest = PaymentRequest().apply { this.amount = Optional.of(amount) @@ -91,7 +96,15 @@ object CashuPaymentHelper { val encoded = paymentRequest.encode() Log.d(TAG, "Created payment request: $encoded") - encoded + try { + val cdkRequest = org.cashudevkit.PaymentRequest.fromString(encoded) + val bech32 = cdkRequest.toBech32String() + Log.d(TAG, "Converted to bech32: $bech32") + GeneratedPaymentRequest(encoded, bech32) + } catch (e: Throwable) { + Log.e(TAG, "Error converting to bech32, returning CREQA format: ${e.message}", e) + GeneratedPaymentRequest(encoded, encoded) + } } catch (e: Exception) { Log.e(TAG, "Error creating payment request: ${e.message}", e) null @@ -99,7 +112,7 @@ object CashuPaymentHelper { } @JvmStatic - fun createPaymentRequest(amount: Long, description: String?): String? = + fun createPaymentRequest(amount: Long, description: String?): GeneratedPaymentRequest? = createPaymentRequest(amount, description, null) @JvmStatic @@ -108,7 +121,7 @@ object CashuPaymentHelper { description: String?, allowedMints: List?, nprofile: String, - ): String? { + ): GeneratedPaymentRequest? { return try { val paymentRequest = PaymentRequest().apply { this.amount = Optional.of(amount) @@ -144,7 +157,15 @@ object CashuPaymentHelper { val encoded = paymentRequest.encode() Log.d(TAG, "Created Nostr payment request: $encoded") - encoded + try { + val cdkRequest = org.cashudevkit.PaymentRequest.fromString(encoded) + val bech32 = cdkRequest.toBech32String() + Log.d(TAG, "Converted Nostr to bech32: $bech32") + GeneratedPaymentRequest(encoded, bech32) + } catch (e: Throwable) { + Log.e(TAG, "Error converting to bech32, returning CREQA format: ${e.message}", e) + GeneratedPaymentRequest(encoded, encoded) + } } catch (e: Exception) { Log.e(TAG, "Error creating Nostr payment request: ${e.message}", e) null @@ -233,7 +254,7 @@ object CashuPaymentHelper { @JvmStatic fun isCashuPaymentRequest(text: String?): Boolean = - text != null && text.startsWith("creqA") + text != null && (text.startsWith("creqA") || text.lowercase().startsWith("creqb")) // === Validation using CDK Token ======================================== diff --git a/app/src/main/java/com/electricdreams/numo/ndef/NdefHostCardEmulationService.java b/app/src/main/java/com/electricdreams/numo/ndef/NdefHostCardEmulationService.java index 5ebaaa1b..7b35fb4a 100644 --- a/app/src/main/java/com/electricdreams/numo/ndef/NdefHostCardEmulationService.java +++ b/app/src/main/java/com/electricdreams/numo/ndef/NdefHostCardEmulationService.java @@ -169,10 +169,13 @@ public byte[] processCommandApdu(byte[] commandApdu, Bundle extras) { Log.i(TAG, "Command: " + description); } - // Start/reset NFC reading indicator when we receive APDU commands - // Only track reading if we have a payment callback set (meaning we're expecting a payment) - if (paymentCallback != null) { - startOrResetNfcReading(); + // Check if this is an UPDATE BINARY command (writing to NDEF) + // If so, and we have a callback, trigger the NFC reading UI + if (paymentCallback != null && commandApdu != null && commandApdu.length >= 2) { + // UPDATE BINARY header is 00 D6 + if (commandApdu[0] == (byte)0x00 && commandApdu[1] == (byte)0xD6) { + startOrResetNfcReading(); + } } // Try to process with the NDEF processor diff --git a/app/src/main/java/com/electricdreams/numo/payment/LightningMintHandler.kt b/app/src/main/java/com/electricdreams/numo/payment/LightningMintHandler.kt index 8cb1622d..cafa5e7c 100644 --- a/app/src/main/java/com/electricdreams/numo/payment/LightningMintHandler.kt +++ b/app/src/main/java/com/electricdreams/numo/payment/LightningMintHandler.kt @@ -1,6 +1,8 @@ package com.electricdreams.numo.payment +import android.content.Context import android.util.Log +import com.electricdreams.numo.R import com.electricdreams.numo.core.cashu.CashuWalletManager import com.google.gson.Gson import com.google.gson.JsonObject @@ -43,6 +45,7 @@ import kotlin.coroutines.resumeWithException * @param uiScope Coroutine scope for UI callbacks */ class LightningMintHandler( + private val context: Context, private val preferredMint: String?, private val allowedMints: List, private val uiScope: CoroutineScope, @@ -51,10 +54,11 @@ class LightningMintHandler( ) { // Secondary constructor to maintain compatibility constructor( + context: Context, preferredMint: String?, allowedMints: List, uiScope: CoroutineScope - ) : this(preferredMint, allowedMints, uiScope, Dispatchers.IO) + ) : this(context, preferredMint, allowedMints, uiScope, Dispatchers.IO) /** * Callback interface for Lightning mint events. */ @@ -149,7 +153,7 @@ class LightningMintHandler( Log.d(TAG, "Requesting Lightning mint quote from ${mintUrl.url} for $paymentAmount sats") val mintWallet = wallet.getWallet(mintUrl, CurrencyUnit.Sat) - val quote = mintWallet?.mintQuote(org.cashudevkit.PaymentMethod.Bolt11, quoteAmount, "Numo POS payment of $paymentAmount sats", null) + val quote = mintWallet?.mintQuote(org.cashudevkit.PaymentMethod.Bolt11, quoteAmount, context.getString(R.string.payment_request_lightning_description, paymentAmount), null) ?: throw Exception("Failed to get wallet for mint: ${mintUrl.url}") mintQuote = quote diff --git a/app/src/main/java/com/electricdreams/numo/payment/NostrPaymentHandler.kt b/app/src/main/java/com/electricdreams/numo/payment/NostrPaymentHandler.kt index 16011bf1..e78989ba 100644 --- a/app/src/main/java/com/electricdreams/numo/payment/NostrPaymentHandler.kt +++ b/app/src/main/java/com/electricdreams/numo/payment/NostrPaymentHandler.kt @@ -1,6 +1,7 @@ package com.electricdreams.numo.payment import android.content.Context +import com.electricdreams.numo.R import android.util.Log import com.electricdreams.numo.feature.history.PaymentsHistoryActivity import com.electricdreams.numo.core.util.MintManager @@ -51,6 +52,10 @@ class NostrPaymentHandler( var paymentRequest: String? = null private set + /** The generated payment request string (bech32) */ + var paymentRequestBech32: String? = null + private set + /** * Start a new Nostr payment flow with fresh keys. * @@ -136,7 +141,7 @@ class NostrPaymentHandler( // Create payment request with Nostr transport val request = CashuPaymentHelper.createPaymentRequestWithNostr( paymentAmount, - "Payment of $paymentAmount sats", + context.getString(R.string.payment_request_default_description, paymentAmount), mintsForPaymentRequest, profile ) @@ -147,9 +152,10 @@ class NostrPaymentHandler( return } - paymentRequest = request - Log.d(TAG, "Created payment request with Nostr: $request") - callback.onPaymentRequestReady(request) + paymentRequest = request.original + paymentRequestBech32 = request.bech32 + Log.d(TAG, "Created payment request with Nostr: ${request.original}") + callback.onPaymentRequestReady(request.original) // Stop any existing listener listener?.stop() diff --git a/app/src/main/java/com/electricdreams/numo/payment/PaymentMethodHandler.kt b/app/src/main/java/com/electricdreams/numo/payment/PaymentMethodHandler.kt index 0efd0d18..db6af513 100644 --- a/app/src/main/java/com/electricdreams/numo/payment/PaymentMethodHandler.kt +++ b/app/src/main/java/com/electricdreams/numo/payment/PaymentMethodHandler.kt @@ -44,7 +44,7 @@ class PaymentMethodHandler( val mintsForPaymentRequest = if (mintManager.isSwapFromUnknownMintsEnabled()) null else allowedMints - val paymentRequest = CashuPaymentHelper.createPaymentRequest(amount, "Payment of $amount sats", mintsForPaymentRequest) + val paymentRequest = CashuPaymentHelper.createPaymentRequest(amount, "Payment of $amount sats", mintsForPaymentRequest)?.original ?: run { Toast.makeText(activity, "Failed to create payment request", Toast.LENGTH_SHORT).show() return diff --git a/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt b/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt index 7fc2756b..cecf7e48 100644 --- a/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt +++ b/app/src/main/java/com/electricdreams/numo/payment/PaymentTabManager.kt @@ -1,101 +1,161 @@ package com.electricdreams.numo.payment +import android.animation.ArgbEvaluator +import android.animation.ObjectAnimator +import android.animation.ValueAnimator import android.content.res.Resources import android.view.View +import android.widget.FrameLayout +import android.widget.LinearLayout import android.widget.TextView +import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.electricdreams.numo.R /** - * Manages the payment method tab UI (Cashu vs Lightning). + * Manages the payment method tab UI (Unified vs Cashu vs Lightning). * - * Handles visual state switching between tabs and visibility of QR containers. + * Uses a sliding pill indicator behind text-only tabs. */ class PaymentTabManager( - private val cashuTab: TextView, - private val lightningTab: TextView, + private val tabContainer: FrameLayout, + private val tabIndicator: View, + + private val unifiedTab: LinearLayout, + private val cashuTab: LinearLayout, + private val lightningTab: LinearLayout, + + private val unifiedTabText: TextView, + private val cashuTabText: TextView, + private val lightningTabText: TextView, + + private val unifiedQrContainer: View, private val cashuQrContainer: View, private val lightningQrContainer: View, + + private val unifiedQrImageView: View, + private val unifiedLoadingSpinner: View, + private val lightningLoadingSpinner: View, + private val cashuLoadingSpinner: View, private val cashuQrImageView: View, private val lightningQrImageView: View, + private val resources: Resources, private val theme: Resources.Theme ) { - /** - * Callback for tab selection events. - */ + enum class PaymentTab { + UNIFIED, CASHU, LIGHTNING + } + interface TabSelectionListener { - /** Called when the Lightning tab is selected */ - fun onLightningTabSelected() - - /** Called when the Cashu tab is selected */ - fun onCashuTabSelected() + fun onTabSelected(tab: PaymentTab) } private var listener: TabSelectionListener? = null - private var isLightningSelected = false + private var currentTab: PaymentTab? = null + private var previousTab: PaymentTab? = null + private var isLaidOut = false - /** - * Set up tab click listeners. - * - * @param listener Callback for tab selection events - */ fun setup(listener: TabSelectionListener) { this.listener = listener - - // Default: show Cashu (Nostr) QR - selectCashuTab() - lightningTab.setOnClickListener { selectLightningTab() } - cashuTab.setOnClickListener { selectCashuTab() } + unifiedTab.setOnClickListener { selectTab(PaymentTab.UNIFIED) } + cashuTab.setOnClickListener { selectTab(PaymentTab.CASHU) } + lightningTab.setOnClickListener { selectTab(PaymentTab.LIGHTNING) } + + // Default selection before layout + currentTab = PaymentTab.UNIFIED + + tabContainer.post { + isLaidOut = true + val tabWidth = unifiedTab.width + val lp = tabIndicator.layoutParams as FrameLayout.LayoutParams + lp.width = tabWidth + tabIndicator.layoutParams = lp + + // Apply the current tab state without animation + val tab = currentTab ?: PaymentTab.UNIFIED + currentTab = null // reset so selectTab doesn't short-circuit + selectTab(tab, animate = false) + } } - /** - * Select the Lightning tab. - */ - fun selectLightningTab() { - if (isLightningSelected) return - isLightningSelected = true - - // Visual state - lightningTab.setTextColor(resources.getColor(R.color.color_bg_white, theme)) - lightningTab.setBackgroundResource(R.drawable.bg_button_primary_green) - cashuTab.setTextColor(resources.getColor(R.color.color_text_secondary, theme)) - cashuTab.setBackgroundResource(android.R.color.transparent) - - // QR visibility - lightningQrContainer.visibility = View.VISIBLE - lightningQrImageView.visibility = View.VISIBLE + fun selectTab(tab: PaymentTab, animate: Boolean = true) { + if (currentTab == tab) return + previousTab = currentTab + currentTab = tab + + if (!isLaidOut) return + + val whiteColor = resources.getColor(R.color.color_bg_white, theme) + val secondaryColor = resources.getColor(R.color.color_text_secondary, theme) + + val targetTab = when (tab) { + PaymentTab.UNIFIED -> unifiedTab + PaymentTab.CASHU -> cashuTab + PaymentTab.LIGHTNING -> lightningTab + } + val targetX = targetTab.left.toFloat() + + if (animate) { + ObjectAnimator.ofFloat(tabIndicator, "translationX", targetX).apply { + duration = 250 + interpolator = FastOutSlowInInterpolator() + start() + } + animateColors(tab, whiteColor, secondaryColor) + } else { + tabIndicator.translationX = targetX + applyColors(tab, whiteColor, secondaryColor) + } + + // QR container visibility + unifiedQrContainer.visibility = View.INVISIBLE cashuQrContainer.visibility = View.INVISIBLE - cashuQrImageView.visibility = View.INVISIBLE + lightningQrContainer.visibility = View.INVISIBLE + + when (tab) { + PaymentTab.UNIFIED -> unifiedQrContainer.visibility = View.VISIBLE + PaymentTab.CASHU -> cashuQrContainer.visibility = View.VISIBLE + PaymentTab.LIGHTNING -> lightningQrContainer.visibility = View.VISIBLE + } - listener?.onLightningTabSelected() + listener?.onTabSelected(tab) } - /** - * Select the Cashu tab. - */ - fun selectCashuTab() { - isLightningSelected = false - - // Visual state - cashuTab.setTextColor(resources.getColor(R.color.color_bg_white, theme)) - cashuTab.setBackgroundResource(R.drawable.bg_button_primary_green) - lightningTab.setTextColor(resources.getColor(R.color.color_text_secondary, theme)) - lightningTab.setBackgroundResource(android.R.color.transparent) - - // QR visibility - cashuQrContainer.visibility = View.VISIBLE - cashuQrImageView.visibility = View.VISIBLE - lightningQrContainer.visibility = View.INVISIBLE - lightningQrImageView.visibility = View.INVISIBLE + fun getCurrentTab(): PaymentTab = currentTab ?: PaymentTab.UNIFIED - // Notify listener that Cashu tab is now selected - listener?.onCashuTabSelected() + private fun applyColors(selectedTab: PaymentTab, whiteColor: Int, secondaryColor: Int) { + unifiedTabText.setTextColor(if (selectedTab == PaymentTab.UNIFIED) whiteColor else secondaryColor) + cashuTabText.setTextColor(if (selectedTab == PaymentTab.CASHU) whiteColor else secondaryColor) + lightningTabText.setTextColor(if (selectedTab == PaymentTab.LIGHTNING) whiteColor else secondaryColor) } - /** - * Check if Lightning tab is currently visible/selected. - */ - fun isLightningTabSelected(): Boolean = isLightningSelected -} + private fun animateColors(selectedTab: PaymentTab, whiteColor: Int, secondaryColor: Int) { + val evaluator = ArgbEvaluator() + + ValueAnimator.ofFloat(0f, 1f).apply { + duration = 250 + addUpdateListener { animation -> + val fraction = animation.animatedFraction + val toWhite = evaluator.evaluate(fraction, secondaryColor, whiteColor) as Int + val toGray = evaluator.evaluate(fraction, whiteColor, secondaryColor) as Int + + // Selected tab fades to white + when (selectedTab) { + PaymentTab.UNIFIED -> unifiedTabText.setTextColor(toWhite) + PaymentTab.CASHU -> cashuTabText.setTextColor(toWhite) + PaymentTab.LIGHTNING -> lightningTabText.setTextColor(toWhite) + } + // Previously selected tab fades to gray + when (previousTab) { + PaymentTab.UNIFIED -> unifiedTabText.setTextColor(toGray) + PaymentTab.CASHU -> cashuTabText.setTextColor(toGray) + PaymentTab.LIGHTNING -> lightningTabText.setTextColor(toGray) + null -> {} + } + } + start() + } + } +} diff --git a/app/src/main/java/com/electricdreams/numo/ui/components/AmountDisplayManager.kt b/app/src/main/java/com/electricdreams/numo/ui/components/AmountDisplayManager.kt index 5a6ed355..51436b21 100644 --- a/app/src/main/java/com/electricdreams/numo/ui/components/AmountDisplayManager.kt +++ b/app/src/main/java/com/electricdreams/numo/ui/components/AmountDisplayManager.kt @@ -5,6 +5,7 @@ import android.view.View import com.electricdreams.numo.R import android.widget.Button import android.widget.TextView +import com.electricdreams.numo.core.cashu.CashuWalletManager import android.widget.Toast import com.electricdreams.numo.core.model.Amount import com.electricdreams.numo.core.prefs.PreferenceStore @@ -167,14 +168,24 @@ class AmountDisplayManager( // Update submit button if (satsValue > 0) { - // Always show a simple, localized "Charge" label without the amount - submitButton.text = context.getString(R.string.pos_charge_button) - submitButton.isEnabled = true requestedAmount = satsValue + val isReady = CashuWalletManager.isWalletReady.value + if (isReady) { + submitButton.text = context.getString(R.string.pos_charge_button) + submitButton.isEnabled = true + } else { + submitButton.text = context.getString(R.string.pos_charge_button_loading) + submitButton.isEnabled = false + } } else { - submitButton.text = context.getString(R.string.pos_charge_button) - submitButton.isEnabled = false requestedAmount = 0 + val isReady = CashuWalletManager.isWalletReady.value + if (isReady) { + submitButton.text = context.getString(R.string.pos_charge_button) + } else { + submitButton.text = context.getString(R.string.pos_charge_button_loading) + } + submitButton.isEnabled = false } } diff --git a/app/src/main/java/com/electricdreams/numo/ui/components/PosUiCoordinator.kt b/app/src/main/java/com/electricdreams/numo/ui/components/PosUiCoordinator.kt index 475e5940..ff8688ec 100644 --- a/app/src/main/java/com/electricdreams/numo/ui/components/PosUiCoordinator.kt +++ b/app/src/main/java/com/electricdreams/numo/ui/components/PosUiCoordinator.kt @@ -10,6 +10,9 @@ import android.widget.GridLayout import android.widget.ImageButton import android.widget.ProgressBar import android.widget.TextView +import androidx.lifecycle.lifecycleScope +import com.electricdreams.numo.core.cashu.CashuWalletManager +import kotlinx.coroutines.launch import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout import com.electricdreams.numo.R @@ -59,6 +62,29 @@ class PosUiCoordinator( initializeViews() initializeManagers() setupNavigationButtons() + + // Disable charge button initially, enable when wallet is ready + submitButton.isEnabled = false + submitButton.alpha = 0.5f + + if (true) { + activity.lifecycleScope.launch { + CashuWalletManager.isWalletReady.collect { isReady -> + // Make sure we only enable it if we don't have a spinner shown + if (submitButtonSpinner.visibility != android.view.View.VISIBLE) { + submitButton.isEnabled = isReady + submitButton.alpha = if (isReady) 1.0f else 0.5f + + // Rely on AmountDisplayManager to set correct text/state based on amount and wallet readiness + amountDisplayManager.updateDisplay( + satoshiInput, + fiatInput, + AmountDisplayManager.AnimationType.NONE + ) + } + } + } + } } /** Handle initial payment amount from basket */ @@ -297,6 +323,9 @@ class PosUiCoordinator( fun hideChargeButtonSpinner() { submitButtonSpinner.visibility = View.GONE submitButton.text = activity.getString(R.string.pos_charge_button) // Restore button text - submitButton.isEnabled = true + // Only re-enable if the wallet is ready + val isReady = CashuWalletManager.isWalletReady.value + submitButton.isEnabled = isReady + submitButton.alpha = if (isReady) 1.0f else 0.5f } } diff --git a/app/src/main/res/drawable/bg_tab_indicator_pill.xml b/app/src/main/res/drawable/bg_tab_indicator_pill.xml new file mode 100644 index 00000000..2b6aa691 --- /dev/null +++ b/app/src/main/res/drawable/bg_tab_indicator_pill.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/activity_payment_request.xml b/app/src/main/res/layout/activity_payment_request.xml index c894fe4b..0a2babc2 100644 --- a/app/src/main/res/layout/activity_payment_request.xml +++ b/app/src/main/res/layout/activity_payment_request.xml @@ -169,7 +169,17 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:adjustViewBounds="true" - android:scaleType="fitCenter" /> + android:scaleType="fitCenter" + android:visibility="invisible" /> + + + + + + + + + + + + - - + - + + android:background="@drawable/bg_tab_indicator_pill" /> + + + + + + + + + + + + + + + + + + + + + + + - - Copiar entrada Borrar registros de errores ¿Borrar todos los registros de errores almacenados? Esta acción no se puede deshacer. + + Unificado + Pago de %1$d sats + Pago Numo POS de %1$d sats diff --git a/app/src/main/res/values-es/strings_pos.xml b/app/src/main/res/values-es/strings_pos.xml index 4c89d290..14e12124 100644 --- a/app/src/main/res/values-es/strings_pos.xml +++ b/app/src/main/res/values-es/strings_pos.xml @@ -28,4 +28,5 @@ Rehacer cambio Deshacer Rehacer + Iniciando billetera... diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 4fd71a63..8e306749 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -815,4 +815,8 @@ Copiar entrada Limpar registros de erros Limpar todos os registros de erros armazenados? Esta ação não pode ser desfeita. + + Unificado + Pagamento de %1$d sats + Pagamento Numo POS de %1$d sats diff --git a/app/src/main/res/values-pt/strings_pos.xml b/app/src/main/res/values-pt/strings_pos.xml index 090860c4..040338e2 100644 --- a/app/src/main/res/values-pt/strings_pos.xml +++ b/app/src/main/res/values-pt/strings_pos.xml @@ -28,4 +28,5 @@ Refazer alteração Desfazer Refazer + Iniciando carteira... diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdc876de..27b4ed1a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -821,4 +821,8 @@ Copy Entry Clear Error Logs Clear all stored error logs? This cannot be undone. + + Unified + Payment of %1$d sats + Numo POS payment of %1$d sats diff --git a/app/src/main/res/values/strings_pos.xml b/app/src/main/res/values/strings_pos.xml index 46b2a90c..4e24771c 100644 --- a/app/src/main/res/values/strings_pos.xml +++ b/app/src/main/res/values/strings_pos.xml @@ -28,4 +28,5 @@ Redo change Undo Redo + Starting wallet... diff --git a/app/src/test/java/com/electricdreams/numo/ndef/PaymentRequestMintBehaviorTest.kt b/app/src/test/java/com/electricdreams/numo/ndef/PaymentRequestMintBehaviorTest.kt index 1466b74c..7e6d2935 100644 --- a/app/src/test/java/com/electricdreams/numo/ndef/PaymentRequestMintBehaviorTest.kt +++ b/app/src/test/java/com/electricdreams/numo/ndef/PaymentRequestMintBehaviorTest.kt @@ -24,7 +24,7 @@ class PaymentRequestMintBehaviorTest { amount = 100L, description = "Test", allowedMints = null, - ) + )?.original assertFalse("PaymentRequest should have been encoded", encoded.isNullOrBlank()) @@ -47,7 +47,7 @@ class PaymentRequestMintBehaviorTest { amount = 42L, description = "With mints", allowedMints = allowedMints, - ) + )?.original assertFalse("PaymentRequest should have been encoded", encoded.isNullOrBlank()) @@ -68,7 +68,7 @@ class PaymentRequestMintBehaviorTest { description = "Nostr no mints", allowedMints = null, nprofile = nprofile, - ) + )?.original assertFalse("PaymentRequest should have been encoded", encoded.isNullOrBlank()) @@ -93,7 +93,7 @@ class PaymentRequestMintBehaviorTest { description = "Nostr with mints", allowedMints = allowedMints, nprofile = nprofile, - ) + )?.original assertFalse("PaymentRequest should have been encoded", encoded.isNullOrBlank()) diff --git a/app/src/test/java/com/electricdreams/numo/payment/LightningMintHandlerTest.kt b/app/src/test/java/com/electricdreams/numo/payment/LightningMintHandlerTest.kt index b56791bd..4f060bbc 100644 --- a/app/src/test/java/com/electricdreams/numo/payment/LightningMintHandlerTest.kt +++ b/app/src/test/java/com/electricdreams/numo/payment/LightningMintHandlerTest.kt @@ -21,6 +21,9 @@ import okhttp3.WebSocketListener import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.mockito.kotlin.anyOrNull +import android.content.Context +import com.electricdreams.numo.R +import org.mockito.kotlin.whenever import org.cashudevkit.Amount import org.cashudevkit.MintQuote import org.cashudevkit.MintUrl @@ -62,6 +65,8 @@ class LightningMintHandlerTest { private lateinit var mockCallback: LightningMintHandler.Callback @Mock private lateinit var mockMintQuote: MintQuote + @Mock + private lateinit var mockContext: Context private lateinit var mockWebServer: MockWebServer private lateinit var testScope: TestScope @@ -80,6 +85,7 @@ class LightningMintHandlerTest { @Before fun setup() { MockitoAnnotations.openMocks(this) + whenever(mockContext.getString(org.mockito.kotlin.any(), org.mockito.kotlin.any())).thenReturn("Numo POS payment of 100 sats") // Setup coroutines Dispatchers.setMain(UnconfinedTestDispatcher()) @@ -101,7 +107,7 @@ class LightningMintHandlerTest { } // Setup handler with dynamic server url and injected dispatcher - handler = LightningMintHandler(useUrl, listOf(useUrl), testScope, UnconfinedTestDispatcher()) + handler = LightningMintHandler(mockContext, useUrl, listOf(useUrl), testScope, UnconfinedTestDispatcher()) // Setup default mock behaviors `when`(mockMintQuote.request).thenReturn(bolt11) @@ -141,7 +147,7 @@ class LightningMintHandlerTest { fun startFailsWhenNoMintsConfigured() { runBlocking { // Given - val emptyHandler = LightningMintHandler(null, emptyList(), testScope) + val emptyHandler = LightningMintHandler(mockContext, null, emptyList(), testScope) // When emptyHandler.start(paymentAmount, mockCallback) @@ -162,7 +168,7 @@ class LightningMintHandlerTest { `when`(mockWallet.mintQuote(any(), any(), any(), any())).thenThrow(RuntimeException("Wallet rejected URL")) // Given invalid mint URL - val invalidHandler = LightningMintHandler("http://exa mple.com", listOf("http://exa mple.com"), testScope, UnconfinedTestDispatcher()) + val invalidHandler = LightningMintHandler(mockContext, "http://exa mple.com", listOf("http://exa mple.com"), testScope, UnconfinedTestDispatcher()) // When invalidHandler.start(paymentAmount, mockCallback) @@ -182,7 +188,7 @@ class LightningMintHandlerTest { val testScope = TestScope(testDispatcher) // Use StandardTestDispatcher for the IO dispatcher too so we can control time - val handler = LightningMintHandler(mintUrlStr, listOf(mintUrlStr), testScope, testDispatcher) + val handler = LightningMintHandler(mockContext, mintUrlStr, listOf(mintUrlStr), testScope, testDispatcher) // We stub mintQuote to return successfully doReturn(mockMintQuote).`when`(mockWallet).mintQuote(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull())