diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b04e7ba3..d7982641 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 73f8770f..b45d00e6 100644 --- a/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt @@ -52,18 +52,29 @@ 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 unifiedTabIcon: TextView + private lateinit var cashuTabIcon: ImageView + private lateinit var lightningTabIcon: ImageView 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 +89,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 +105,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 +164,29 @@ 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) + unifiedTabIcon = findViewById(R.id.unified_tab_icon) + cashuTabIcon = findViewById(R.id.cashu_tab_icon) + lightningTabIcon = findViewById(R.id.lightning_tab_icon) 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 +204,22 @@ class PaymentRequestActivity : AppCompatActivity() { // Initialize tab manager tabManager = PaymentTabManager( + unifiedTab = unifiedTab, cashuTab = cashuTab, lightningTab = lightningTab, + unifiedTabText = unifiedTabText, + cashuTabText = cashuTabText, + lightningTabText = lightningTabText, + unifiedTabIcon = unifiedTabIcon, + cashuTabIcon = cashuTabIcon, + lightningTabIcon = lightningTabIcon, + unifiedQrContainer = unifiedQrContainer, cashuQrContainer = cashuQrContainer, lightningQrContainer = lightningQrContainer, + unifiedQrImageView = unifiedQrImageView, + unifiedLoadingSpinner = unifiedLoadingSpinner, + lightningLoadingSpinner = lightningLoadingSpinner, + cashuLoadingSpinner = cashuLoadingSpinner, cashuQrImageView = cashuQrImageView, lightningQrImageView = lightningQrImageView, resources = resources, @@ -193,25 +228,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 +305,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 +335,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 +509,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 +530,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 +600,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 +681,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 +760,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 +772,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 +783,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 +807,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 @@ -784,15 +915,21 @@ class PaymentRequestActivity : AppCompatActivity() { } } - override fun onNfcReadingStopped() { + override fun onNfcReadingStopped(failedInMiddleOfTransaction: Boolean) { runOnUiThread { - Log.w(TAG, "NFC reading stopped callback received") + Log.w(TAG, "NFC reading stopped callback received (failedInMiddleOfTransaction: $failedInMiddleOfTransaction)") if (hasTerminalOutcome) { Log.d(TAG, "NFC reading stopped ignored - terminal outcome already set") return@runOnUiThread } - Log.d(TAG, "NFC reading stopped - keeping animation overlay active") + if (failedInMiddleOfTransaction) { + Log.e(TAG, "NFC connection lost while writing data - failing payment") + handlePaymentError(getString(R.string.payment_failure_button_try_again)) + } else { + Log.d(TAG, "NFC connection stopped without writing data. Returning to payment request screen.") + hideNfcAnimationOverlay() + } } } }) @@ -1249,6 +1386,16 @@ class PaymentRequestActivity : AppCompatActivity() { nfcAnimationView.startProcessing() } + private fun hideNfcAnimationOverlay() { + nfcAnimationContainer.visibility = View.GONE + nfcAnimationView.reset() + resetResultTextViews() + resetResultActionButtons() + restoreSystemBarsAfterAnimation() + cancelNfcSafetyTimeout() + isProcessingNfcPayment = false + } + private fun startNfcSafetyTimeout() { cancelNfcSafetyTimeout() @@ -1264,7 +1411,10 @@ class PaymentRequestActivity : AppCompatActivity() { } private fun cancelNfcSafetyTimeout() { - nfcAnimationTimeoutRunnable?.let { nfcTimeoutHandler.removeCallbacks(it) } + nfcAnimationTimeoutRunnable?.let { + Log.d(TAG, "Cancelling Activity NFC safety timeout") + nfcTimeoutHandler.removeCallbacks(it) + } nfcAnimationTimeoutRunnable = null } 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 c666fdea..46ba03d1 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 @@ -17,6 +17,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 /** @@ -28,6 +31,10 @@ import org.cashudevkit.generateMnemonic * The wallet's mnemonic (seed phrase) and SQLite database are both * persisted so that balances survive app restarts. */ +enum class WalletState { + UNINITIALIZED, LOADING, READY, ERROR +} + object CashuWalletManager : MintManager.MintChangeListener { private const val TAG = "CashuWalletManager" @@ -43,9 +50,8 @@ object CashuWalletManager : MintManager.MintChangeListener { @Volatile private var wallet: WalletRepository? = null - @Volatile - var isWalletLoading: Boolean = false - private set + private val _walletState = MutableStateFlow(WalletState.UNINITIALIZED) + val walletState: StateFlow = _walletState.asStateFlow() /** Initialize from ModernPOSActivity. Safe to call multiple times. */ fun init(context: Context) { @@ -164,6 +170,7 @@ object CashuWalletManager : MintManager.MintChangeListener { database = db wallet = newWallet + _walletState.value = WalletState.READY Log.d(TAG, "Wallet restore complete. Restored ${mints.size} mints.") @@ -400,7 +407,7 @@ object CashuWalletManager : MintManager.MintChangeListener { * Runs on our IO coroutine scope. */ private suspend fun rebuildWallet(mints: List) { - isWalletLoading = true + _walletState.value = WalletState.LOADING try { // Close any previous instances closeResources() @@ -446,6 +453,7 @@ object CashuWalletManager : MintManager.MintChangeListener { database = db wallet = newWallet + _walletState.value = WalletState.READY Log.d(TAG, "Initialized WalletRepository with ${mints.size} mints; DB=${dbFile.absolutePath}") @@ -453,8 +461,7 @@ object CashuWalletManager : MintManager.MintChangeListener { BalanceRefreshBroadcast.send(appContext, "wallet_initialized") } catch (t: Throwable) { Log.e(TAG, "Failed to initialize WalletRepository", t) - } finally { - isWalletLoading = false + _walletState.value = WalletState.ERROR } } diff --git a/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt b/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt index 74d82d88..e0a968ca 100644 --- a/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/feature/history/PaymentsHistoryActivity.kt @@ -136,7 +136,7 @@ class PaymentsHistoryActivity : AppCompatActivity() { } private fun loadBalance() { - if (CashuWalletManager.isWalletLoading) { + if (CashuWalletManager.walletState.value == com.electricdreams.numo.core.cashu.WalletState.LOADING) { binding.balanceFiat?.text = "..." binding.balanceSats?.visibility = View.GONE return 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 fae9d072..ebc9b9ec 100644 --- a/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt +++ b/app/src/main/java/com/electricdreams/numo/ndef/CashuPaymentHelper.kt @@ -38,6 +38,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() @@ -69,7 +74,7 @@ object CashuPaymentHelper { amount: Long, description: String?, allowedMints: List?, - ): String? { + ): GeneratedPaymentRequest? { return try { val paymentRequest = PaymentRequest().apply { this.amount = Optional.of(amount) @@ -92,7 +97,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 @@ -100,7 +113,7 @@ object CashuPaymentHelper { } @JvmStatic - fun createPaymentRequest(amount: Long, description: String?): String? = + fun createPaymentRequest(amount: Long, description: String?): GeneratedPaymentRequest? = createPaymentRequest(amount, description, null) @JvmStatic @@ -109,7 +122,7 @@ object CashuPaymentHelper { description: String?, allowedMints: List?, nprofile: String, - ): String? { + ): GeneratedPaymentRequest? { return try { val paymentRequest = PaymentRequest().apply { this.amount = Optional.of(amount) @@ -145,7 +158,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 @@ -234,7 +255,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..70d64a57 100644 --- a/app/src/main/java/com/electricdreams/numo/ndef/NdefHostCardEmulationService.java +++ b/app/src/main/java/com/electricdreams/numo/ndef/NdefHostCardEmulationService.java @@ -43,9 +43,10 @@ public class NdefHostCardEmulationService extends HostApduService { // NFC reading state tracking private boolean isNfcReading = false; + private boolean isNfcWriting = false; // Tracks if payer actually started writing private Handler nfcTimeoutHandler; private Runnable nfcTimeoutRunnable; - private static final long NFC_TIMEOUT_MS = 2000; // 2 seconds + private static final long NFC_TIMEOUT_MS = 3500; // 3.5 seconds /** * Callback interface for Cashu payments @@ -54,7 +55,7 @@ public interface CashuPaymentCallback { void onCashuTokenReceived(String token); void onCashuPaymentError(String errorMessage); void onNfcReadingStarted(); - void onNfcReadingStopped(); + void onNfcReadingStopped(boolean failedInMiddleOfTransaction); } /** @@ -76,7 +77,7 @@ public void onCreate() { @Override public void run() { Log.i(TAG, "NFC reading timeout - no APDU received for " + NFC_TIMEOUT_MS + "ms"); - stopNfcReading(); + stopNfcReading(false); } }; @@ -91,7 +92,7 @@ public void onNdefMessageReceived(String message) { Log.i(TAG, "Message content: " + message); // Stop NFC reading indicator since we received the complete message - stopNfcReading(); + stopNfcReading(true); // First try to extract a Cashu token from the message String cashuToken = CashuPaymentHelper.extractCashuToken(message); @@ -169,10 +170,20 @@ 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) + // Start or reset NFC reading indicator for any APDU command when a payment is expected if (paymentCallback != null) { startOrResetNfcReading(); + + // Track if the payer started writing data (UPDATE BINARY) + if (commandApdu != null && commandApdu.length >= 2) { + // UPDATE BINARY header is 00 D6 + if (commandApdu[0] == (byte)0x00 && commandApdu[1] == (byte)0xD6) { + if (!isNfcWriting) { + Log.i(TAG, "Payer started writing data (UPDATE BINARY received)"); + isNfcWriting = true; + } + } + } } // Try to process with the NDEF processor @@ -240,7 +251,7 @@ public void clearPaymentRequest() { this.expectedAmount = 0; // Stop NFC reading if active - stopNfcReading(); + stopNfcReading(true); // Don't trigger failure UI on manual clear if (ndefProcessor != null) { ndefProcessor.setMessageToSend(""); @@ -374,6 +385,7 @@ private void startOrResetNfcReading() { } // Reset the timeout - cancel any pending timeout and schedule a new one + Log.d(TAG, "Resetting NFC reading timeout timer (" + NFC_TIMEOUT_MS + "ms)"); nfcTimeoutHandler.removeCallbacks(nfcTimeoutRunnable); nfcTimeoutHandler.postDelayed(nfcTimeoutRunnable, NFC_TIMEOUT_MS); } @@ -382,18 +394,23 @@ private void startOrResetNfcReading() { * Stop the NFC reading indicator * Called when the NDEF message is received or timeout occurs */ - private void stopNfcReading() { + private void stopNfcReading(boolean isSuccess) { if (isNfcReading) { - Log.i(TAG, "NFC reading stopped"); + Log.i(TAG, "NFC reading stopped. Was writing data: " + isNfcWriting + " Success: " + isSuccess); isNfcReading = false; // Cancel any pending timeout + Log.d(TAG, "Cancelling NFC reading timeout timer"); nfcTimeoutHandler.removeCallbacks(nfcTimeoutRunnable); - // Notify callback - if (paymentCallback != null) { - paymentCallback.onNfcReadingStopped(); + // Notify callback ONLY if it's NOT a success. + // If it is a success, the higher-level logic handles the UI transition via onCashuTokenReceived. + if (paymentCallback != null && !isSuccess) { + paymentCallback.onNfcReadingStopped(isNfcWriting); } + + // Reset writing flag for next session + isNfcWriting = false; } } 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 ae891dab..ac049ba7 100644 --- a/app/src/main/java/com/electricdreams/numo/payment/PaymentMethodHandler.kt +++ b/app/src/main/java/com/electricdreams/numo/payment/PaymentMethodHandler.kt @@ -45,7 +45,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, R.string.payment_toast_failed_create_request, Toast.LENGTH_SHORT).show() return @@ -101,9 +101,9 @@ class PaymentMethodHandler( } } - override fun onNfcReadingStopped() { + override fun onNfcReadingStopped(failedInMiddleOfTransaction: Boolean) { activity.runOnUiThread { - onStatusUpdate("NFC reading stopped") + onStatusUpdate(if (failedInMiddleOfTransaction) "NFC reading interrupted" else "NFC reading stopped") } } }) 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..457ce784 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,143 @@ package com.electricdreams.numo.payment +import android.animation.LayoutTransition import android.content.res.Resources import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView 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. */ class PaymentTabManager( - private val cashuTab: TextView, - private val lightningTab: TextView, + 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 unifiedTabIcon: TextView, + private val cashuTabIcon: ImageView, + private val lightningTabIcon: ImageView, + + 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 ) { + enum class PaymentTab { + UNIFIED, CASHU, LIGHTNING + } + /** * Callback for tab selection events. */ 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 /** * Set up tab click listeners. - * - * @param listener Callback for tab selection events */ fun setup(listener: TabSelectionListener) { this.listener = listener + + // Enable layout transitions for smooth text appearance/disappearance + (unifiedTab.parent as? ViewGroup)?.layoutTransition = LayoutTransition().apply { + enableTransitionType(LayoutTransition.CHANGING) + } - // Default: show Cashu (Nostr) QR - selectCashuTab() + // Default: show Unified + selectTab(PaymentTab.UNIFIED) - lightningTab.setOnClickListener { selectLightningTab() } - cashuTab.setOnClickListener { selectCashuTab() } + unifiedTab.setOnClickListener { selectTab(PaymentTab.UNIFIED) } + cashuTab.setOnClickListener { selectTab(PaymentTab.CASHU) } + lightningTab.setOnClickListener { selectTab(PaymentTab.LIGHTNING) } } - /** - * 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 - cashuQrContainer.visibility = View.INVISIBLE - cashuQrImageView.visibility = View.INVISIBLE + fun selectTab(tab: PaymentTab) { + if (currentTab == tab) return + currentTab = tab - listener?.onLightningTabSelected() - } + val primaryBg = R.drawable.bg_button_primary_green + val transparentBg = android.R.color.transparent + val whiteColor = resources.getColor(R.color.color_bg_white, theme) + val secondaryColor = resources.getColor(R.color.color_text_secondary, theme) - /** - * Select the Cashu tab. - */ - fun selectCashuTab() { - isLightningSelected = false + // Reset all + unifiedTab.setBackgroundResource(transparentBg) + cashuTab.setBackgroundResource(transparentBg) + lightningTab.setBackgroundResource(transparentBg) + + unifiedTabText.visibility = View.GONE + cashuTabText.visibility = View.GONE + lightningTabText.visibility = View.GONE - // 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 + unifiedTabIcon.visibility = View.VISIBLE + cashuTabIcon.visibility = View.VISIBLE + lightningTabIcon.visibility = View.VISIBLE + + unifiedTabText.setTextColor(secondaryColor) + cashuTabText.setTextColor(secondaryColor) + lightningTabText.setTextColor(secondaryColor) + + unifiedTabIcon.setTextColor(secondaryColor) + cashuTabIcon.setColorFilter(secondaryColor) + lightningTabIcon.setColorFilter(secondaryColor) + + unifiedQrContainer.visibility = View.INVISIBLE + cashuQrContainer.visibility = View.INVISIBLE lightningQrContainer.visibility = View.INVISIBLE - lightningQrImageView.visibility = View.INVISIBLE - // Notify listener that Cashu tab is now selected - listener?.onCashuTabSelected() + // Set selected + when (tab) { + PaymentTab.UNIFIED -> { + unifiedTab.setBackgroundResource(primaryBg) + unifiedTabText.visibility = View.VISIBLE + unifiedTabText.setTextColor(whiteColor) + unifiedTabIcon.visibility = View.GONE + + unifiedQrContainer.visibility = View.VISIBLE + } + PaymentTab.CASHU -> { + cashuTab.setBackgroundResource(primaryBg) + cashuTabText.visibility = View.VISIBLE + cashuTabText.setTextColor(whiteColor) + cashuTabIcon.visibility = View.GONE + + cashuQrContainer.visibility = View.VISIBLE + } + PaymentTab.LIGHTNING -> { + lightningTab.setBackgroundResource(primaryBg) + lightningTabText.visibility = View.VISIBLE + lightningTabText.setTextColor(whiteColor) + lightningTabIcon.visibility = View.GONE + + lightningQrContainer.visibility = View.VISIBLE + } + } + + listener?.onTabSelected(tab) } - /** - * Check if Lightning tab is currently visible/selected. - */ - fun isLightningTabSelected(): Boolean = isLightningSelected + fun getCurrentTab(): PaymentTab = currentTab ?: PaymentTab.UNIFIED } - 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 b0726b1f..66c5cc86 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.walletState.value == com.electricdreams.numo.core.cashu.WalletState.READY + 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.walletState.value == com.electricdreams.numo.core.cashu.WalletState.READY + 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..fa465f27 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,30 @@ 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.walletState.collect { state -> + val isReady = state == com.electricdreams.numo.core.cashu.WalletState.READY + // 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 +324,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.walletState.value == com.electricdreams.numo.core.cashu.WalletState.READY + submitButton.isEnabled = isReady + submitButton.alpha = if (isReady) 1.0f else 0.5f } } diff --git a/app/src/main/res/layout/activity_payment_request.xml b/app/src/main/res/layout/activity_payment_request.xml index 0dd60ae4..5513c5a6 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:paddingHorizontal="14dp" + android:orientation="horizontal" + android:background="@android:color/transparent"> - + + + + + + + android:paddingHorizontal="14dp" + android:orientation="horizontal" + android:background="@android:color/transparent"> + + + + + + Frase de recuperación Pegar del portapapeles Restablecer PIN + 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 6c38e736..537b7d29 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -848,4 +848,7 @@ Frase de recuperação Colar da área de transferência Redefinir PIN + 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 3836dbee..f5eb675f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -892,4 +892,7 @@ Last updated: November 2024 Recovery Phrase Paste from Clipboard Reset PIN + 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 8edd6e74..e6e21b28 100644 --- a/app/src/main/res/values/strings_pos.xml +++ b/app/src/main/res/values/strings_pos.xml @@ -33,4 +33,5 @@ Your basket is empty Invalid payment amount No more stock available + 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()) diff --git a/docs/NDEF_Payer_Side_Spec.md b/docs/NDEF_Payer_Side_Spec.md index 74883c66..9271943e 100644 --- a/docs/NDEF_Payer_Side_Spec.md +++ b/docs/NDEF_Payer_Side_Spec.md @@ -6,7 +6,7 @@ This document formalizes the **actual NFC/NDEF protocol implemented in this repo It describes exactly what a **payer device** (NFC reader/writer) must do on the wire (APDUs, payloads, chunking) to: -1. Read a **Cashu payment request** from a PoS terminal that emulates a **Type 4 NDEF tag**. +1. Read a **payment payload** (BIP21 URI, Cashu request, or Lightning invoice) from a PoS terminal that emulates a **Type 4 NDEF tag**. 2. Write a **Cashu token** (or equivalent result) back to the PoS. There are **no recommendations** here – this is a description of the behavior @@ -36,7 +36,7 @@ All statements below are derived from that code. - NDEF Tag Application AID: `D2 76 00 00 85 01 01`. - A **Capability Container (CC) file** (`E1 03`). - A **single NDEF file** (`E1 04`). - - When a payment is active, the NDEF file contains a **Cashu payment request** as a + - When a payment is active, the NDEF file contains a **payment payload** (a BIP21 URI, a Cashu payment request, or a Lightning invoice) as a single NDEF **Text** record. - After the payer writes back an NDEF message, the PoS extracts a **Cashu token** from the text/URI content and processes the payment. @@ -54,8 +54,8 @@ All statements below are derived from that code. 2. Payer **selects the NDEF Tag Application** via ISO 7816 AID SELECT. 3. Payer may **select and read the CC file** to learn file IDs and (advertised) limits. 4. Payer **selects the NDEF file** and **reads** the NDEF message containing the - Cashu payment request. -5. Payer processes the Cashu payment request off‑tag. + payment payload. +5. Payer processes the payload off‑tag (extracting the Cashu request or Lightning invoice as needed). 6. Payer **writes a new NDEF message** (Text or URI record) containing a Cashu token (or URL with token) via one or more UPDATE BINARY APDUs. 7. PoS parses the written NDEF and, if a valid token is present, validates and redeems it. @@ -327,9 +327,14 @@ Later, when the payer sends `SELECT FILE E1 04`, the tag builds an NDEF message ### 4.2 Semantic Content of `messageToSend` -`messageToSend` is the **encoded Cashu payment request string** produced by -`CashuPaymentHelper.createPaymentRequest(...)` or related helpers. It typically -starts with `creqA` and is opaque to NFC; it is simply treated as UTF‑8 text. +`messageToSend` is the payload string. Its format depends on the active tab/mode on the PoS device. It is always encoded as UTF-8 text inside an NDEF Text record. It will be one of the following three formats: + +1. **Unified (Default):** A BIP21 URI containing both a Bech32-encoded Cashu request and a Lightning invoice. + Format: `bitcoin:?creq={cashu_bech32}&lightning={lnbc_invoice}` (Note: The Cashu request is usually Bech32-encoded, starting with `creq1...`, though a fallback to `creqA...` is possible). +2. **Cashu:** A raw Nostr Cashu payment request. + Format: `creqA...` +3. **Lightning:** A standard Bolt11 Lightning invoice. + Format: `lnbc...` From the payer’s point of view, the **payment request NDEF** is: @@ -337,8 +342,7 @@ From the payer’s point of view, the **payment request NDEF** is: - `NLEN` (2 bytes), followed by - A single **Text record** with: - language code `"en"`. - - payload text equal to the ASCII/UTF‑8 encoded Cashu payment request - (e.g. `"creqA..."`). + - payload text equal to the ASCII/UTF‑8 encoded string of one of the above formats. --- @@ -380,7 +384,7 @@ any of the following conditions are met (all derived from ### 4.3 Mint List Semantics in Payment Requests -The Cashu payment request string (`creqA…`) MAY contain a list of allowed mints +The encoded Cashu payment request string (whether represented as raw `creqA...` or Bech32 `creq1...` within the URI) MAY contain a list of allowed mints as part of its encoded payload. How this field is populated depends on the merchant’s settings in the Numo app: @@ -587,7 +591,7 @@ processed. This section consolidates the behavior above into the exact sequences a **payer** should send to interact with this implementation. -### 6.1 Reading the Cashu Payment Request +### 6.1 Reading the Payment Payload Assume the PoS has already called `setPaymentRequest(paymentRequest, amount)` and thus prepared an outgoing NDEF message. @@ -656,8 +660,8 @@ thus prepared an outgoing NDEF message. The payer then parses the NDEF message: - NDEF file framing: Type 4 header (already known from NLEN). -- Single Text record with language `"en"` and text = Cashu payment request string - (`"creqA..."`). +- Single Text record with language `"en"` and text = the payment payload string + (e.g., `"bitcoin:?creq=...&lightning=..."` or `"creqA..."` or `"lnbc..."`). ### 6.2 Writing a Cashu Token Back to the PoS @@ -785,8 +789,9 @@ this implementation is: 2. Optionally **read the CC file** at file ID `E1 03` to discover that the NDEF file ID is `E1 04` and the advertised size and MLe/MLc. 3. **Select the NDEF file** (`00 A4 00 0C 02 E1 04`) and **READ BINARY** to obtain - the payment request NDEF (Text record with `"en"` + Cashu `creqA...` string). -4. Complete the Cashu payment off‑tag. + the payment request NDEF (Text record with `"en"` + the payment payload string + like `"bitcoin:?..."`, `"creqA..."`, or `"lnbc..."`). +4. Parse the payload off-tag to identify whether it is a Unified BIP21 URI, a Cashu payment request, or a Lightning invoice, and complete the payment. 5. **Select the NDEF file again (if needed)** and **UPDATE BINARY** to write a complete Type 4 NDEF file containing a single Text or URI record from which a Cashu token can be extracted, using either: