Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b1e23f2
feat: add unified payment modal with BIP21 URI and CREQB encoding
a1denvalu3 Mar 7, 2026
31bcdac
fix: hide tab icons when tab is selected
a1denvalu3 Mar 7, 2026
287638a
style: remove overlay icon from unified QR code
a1denvalu3 Mar 7, 2026
39ac471
feat: simplify unified tab icon to a simple U character
a1denvalu3 Mar 7, 2026
ca6fd4e
fix: remove onchain fallback from bip21 uris
a1denvalu3 Mar 7, 2026
1afd290
fix: proactively dispatch Lightning invoice on resume to prevent UI s…
a1denvalu3 Mar 7, 2026
ea570cf
fix: restore creqA for cashu tab but use bech32 for unified qr
a1denvalu3 Mar 7, 2026
abf829a
feat: localize unified tab label and payment descriptions
a1denvalu3 Mar 7, 2026
e2cbcf5
fix: delay nfc reading animation until UPDATE BINARY command is received
a1denvalu3 Mar 7, 2026
c10b98d
fix: hide lightning spinner on error and ensure error toast surfaces …
a1denvalu3 Mar 7, 2026
393d2a4
Revert "fix: proactively dispatch Lightning invoice on resume to prev…
a1denvalu3 Mar 7, 2026
eef92b5
fix: disable charge button until cashu wallet is fully initialized
a1denvalu3 Mar 7, 2026
beb1abd
feat: display unified qr code only when both cashu and lightning form…
a1denvalu3 Mar 7, 2026
1764f7c
fix: default to Unified tab when resuming payment from history
a1denvalu3 Mar 7, 2026
625aaad
style: prevent payment tabs container from changing size
a1denvalu3 Mar 7, 2026
088fd04
refactor: improve NFC payment UX with responsive reading animation
a1denvalu3 Mar 11, 2026
89ac4ba
fix: prevent NFC success path from triggering failure UI
a1denvalu3 Mar 11, 2026
08c0802
fix: extend NFC reading timeout to 10s to support slower wallets
a1denvalu3 Mar 11, 2026
d4203f6
fix: adjust NFC reading timeout for slower wallets
a1denvalu3 Mar 11, 2026
0865420
chore: add logging for NFC reading timeout cancellations
a1denvalu3 Mar 11, 2026
6aec64c
refactor: consolidate wallet ready and loading states into a single W…
a1denvalu3 Mar 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
256 changes: 203 additions & 53 deletions app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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"
Expand All @@ -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> = _walletState.asStateFlow()

/** Initialize from ModernPOSActivity. Safe to call multiple times. */
fun init(context: Context) {
Expand Down Expand Up @@ -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.")

Expand Down Expand Up @@ -400,7 +407,7 @@ object CashuWalletManager : MintManager.MintChangeListener {
* Runs on our IO coroutine scope.
*/
private suspend fun rebuildWallet(mints: List<String>) {
isWalletLoading = true
_walletState.value = WalletState.LOADING
try {
// Close any previous instances
closeResources()
Expand Down Expand Up @@ -446,15 +453,15 @@ object CashuWalletManager : MintManager.MintChangeListener {

database = db
wallet = newWallet
_walletState.value = WalletState.READY

Log.d(TAG, "Initialized WalletRepository with ${mints.size} mints; DB=${dbFile.absolutePath}")

// Notify UI that wallet is ready
BalanceRefreshBroadcast.send(appContext, "wallet_initialized")
} catch (t: Throwable) {
Log.e(TAG, "Failed to initialize WalletRepository", t)
} finally {
isWalletLoading = false
_walletState.value = WalletState.ERROR
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -69,7 +74,7 @@ object CashuPaymentHelper {
amount: Long,
description: String?,
allowedMints: List<String>?,
): String? {
): GeneratedPaymentRequest? {
return try {
val paymentRequest = PaymentRequest().apply {
this.amount = Optional.of(amount)
Expand All @@ -92,15 +97,23 @@ 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
}
}

@JvmStatic
fun createPaymentRequest(amount: Long, description: String?): String? =
fun createPaymentRequest(amount: Long, description: String?): GeneratedPaymentRequest? =
createPaymentRequest(amount, description, null)

@JvmStatic
Expand All @@ -109,7 +122,7 @@ object CashuPaymentHelper {
description: String?,
allowedMints: List<String>?,
nprofile: String,
): String? {
): GeneratedPaymentRequest? {
return try {
val paymentRequest = PaymentRequest().apply {
this.amount = Optional.of(amount)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ========================================

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,7 +55,7 @@ public interface CashuPaymentCallback {
void onCashuTokenReceived(String token);
void onCashuPaymentError(String errorMessage);
void onNfcReadingStarted();
void onNfcReadingStopped();
void onNfcReadingStopped(boolean failedInMiddleOfTransaction);
}

/**
Expand All @@ -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);
}
};

Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String>,
private val uiScope: CoroutineScope,
Expand All @@ -51,10 +54,11 @@ class LightningMintHandler(
) {
// Secondary constructor to maintain compatibility
constructor(
context: Context,
preferredMint: String?,
allowedMints: List<String>,
uiScope: CoroutineScope
) : this(preferredMint, allowedMints, uiScope, Dispatchers.IO)
) : this(context, preferredMint, allowedMints, uiScope, Dispatchers.IO)
/**
* Callback interface for Lightning mint events.
*/
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
)
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
}
})
Expand Down
Loading
Loading