Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,9 @@ dependencies {

// Flexbox layout for tag-based category selection
implementation("com.google.android.flexbox:flexbox:3.0.0")

testImplementation("androidx.test:core:1.5.0")
testImplementation("org.robolectric:robolectric:4.11.1")
testImplementation("org.mockito:mockito-core:5.11.0")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ import com.electricdreams.numo.core.util.SavedBasketManager
import com.electricdreams.numo.core.worker.BitcoinPriceWorker
import com.electricdreams.numo.core.util.CurrencyManager
import com.electricdreams.numo.feature.history.PaymentsHistoryActivity
import com.electricdreams.numo.feature.tips.TipSelectionActivity
import com.electricdreams.numo.ndef.CashuPaymentHelper
import com.electricdreams.numo.ndef.NdefHostCardEmulationService
import com.electricdreams.numo.payment.LightningMintHandler
import com.electricdreams.numo.payment.NostrPaymentHandler
import com.electricdreams.numo.payment.PaymentRequestHceManager
import com.electricdreams.numo.payment.PaymentRequestIntentData
import com.electricdreams.numo.payment.PaymentTabManager
import com.electricdreams.numo.payment.PendingPaymentRegistrar
import com.electricdreams.numo.payment.ui.PaymentRequestUi
import com.electricdreams.numo.ui.util.QrCodeGenerator
import com.electricdreams.numo.feature.autowithdraw.AutoWithdrawManager
import kotlinx.coroutines.CoroutineScope
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.electricdreams.numo.payment

import android.util.Log
import com.electricdreams.numo.ndef.NdefHostCardEmulationService

class PaymentRequestHceManager {

private enum class HceMode { CASHU, LIGHTNING }

private var currentMode: HceMode = HceMode.CASHU
private var hcePaymentRequest: String? = null
private var lightningInvoice: String? = null

fun setPaymentRequest(request: String?) {
hcePaymentRequest = request
}

fun setLightningInvoice(invoice: String?) {
lightningInvoice = invoice
}

fun switchToCashu(paymentAmount: Long) {
val request = hcePaymentRequest ?: run {
Log.w(TAG, "switchToCashu() called but hcePaymentRequest is null")
return
}

try {
val hceService = NdefHostCardEmulationService.getInstance()
if (hceService != null) {
Log.d(TAG, "switchToCashu(): Switching HCE payload to Cashu request")
hceService.setPaymentRequest(request, paymentAmount)
currentMode = HceMode.CASHU
} else {
Log.w(TAG, "switchToCashu(): HCE service not available")
}
} catch (e: Exception) {
Log.e(TAG, "switchToCashu(): Error while setting HCE Cashu payload: ${e.message}", e)
}
}

fun switchToLightning() {
val invoice = lightningInvoice ?: run {
Log.w(TAG, "switchToLightning() called but lightningInvoice is null")
return
}
val payload = "lightning:$invoice"

try {
val hceService = NdefHostCardEmulationService.getInstance()
if (hceService != null) {
Log.d(TAG, "switchToLightning(): Switching HCE payload to Lightning invoice. payload=$payload")
hceService.setPaymentRequest(payload, 0L)
currentMode = HceMode.LIGHTNING
} else {
Log.w(TAG, "switchToLightning(): HCE service not available")
}
} catch (e: Exception) {
Log.e(TAG, "switchToLightning(): Error while setting HCE Lightning payload: ${e.message}", e)
}
}

fun clear() {
try {
val hceService = NdefHostCardEmulationService.getInstance()
hceService?.let {
Log.d(TAG, "Clearing HCE service state")
it.clearPaymentRequest()
it.setPaymentCallback(null)
}
} catch (e: Exception) {
Log.e(TAG, "Error clearing HCE service: ${e.message}", e)
}
currentMode = HceMode.CASHU
hcePaymentRequest = null
lightningInvoice = null
}

companion object {
private const val TAG = "PaymentRequestHceManager"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.electricdreams.numo.payment

import android.content.Intent
import android.util.Log
import com.electricdreams.numo.PaymentRequestActivity
import com.electricdreams.numo.core.model.Amount
import com.electricdreams.numo.core.model.Amount.Currency
import com.electricdreams.numo.feature.tips.TipSelectionActivity

/**
* Immutable snapshot of all runtime data required for PaymentRequestActivity.
*/
data class PaymentRequestIntentData(
val paymentAmount: Long,
val formattedAmount: String,
val pendingPaymentId: String?,
val resumeLightningQuoteId: String?,
val resumeLightningMintUrl: String?,
val resumeLightningInvoice: String?,
val resumeNostrSecretHex: String?,
val resumeNostrNprofile: String?,
val checkoutBasketJson: String?,
val savedBasketId: String?,
val tipAmountSats: Long,
val tipPercentage: Int,
val baseAmountSats: Long,
val baseFormattedAmount: String?,
) {
val isResumingPayment: Boolean get() = pendingPaymentId != null
val hasTip: Boolean get() = tipAmountSats > 0 && baseAmountSats > 0

companion object {
private const val TAG = "PaymentRequestIntentData"

fun fromIntent(intent: Intent): PaymentRequestIntentData {
val paymentAmount = intent.getLongExtra(PaymentRequestActivity.EXTRA_PAYMENT_AMOUNT, 0)
val formattedAmount = intent.getStringExtra(PaymentRequestActivity.EXTRA_FORMATTED_AMOUNT)
?: Amount(paymentAmount, Currency.BTC).toString()

val pendingPaymentId = intent.getStringExtra(PaymentRequestActivity.EXTRA_RESUME_PAYMENT_ID)

val tipAmount = intent.getLongExtra(TipSelectionActivity.EXTRA_TIP_AMOUNT_SATS, 0)
val tipPercentage = intent.getIntExtra(TipSelectionActivity.EXTRA_TIP_PERCENTAGE, 0)
val baseAmount = intent.getLongExtra(TipSelectionActivity.EXTRA_BASE_AMOUNT_SATS, 0)
val baseFormatted = intent.getStringExtra(TipSelectionActivity.EXTRA_BASE_FORMATTED_AMOUNT)

if (tipAmount > 0) {
Log.d(
TAG,
"Read tip info from intent: tipAmount=$tipAmount, tipPercent=$tipPercentage%, baseAmount=$baseAmount"
)
}

return PaymentRequestIntentData(
paymentAmount = paymentAmount,
formattedAmount = formattedAmount,
pendingPaymentId = pendingPaymentId,
resumeLightningQuoteId = intent.getStringExtra(PaymentRequestActivity.EXTRA_LIGHTNING_QUOTE_ID),
resumeLightningMintUrl = intent.getStringExtra(PaymentRequestActivity.EXTRA_LIGHTNING_MINT_URL),
resumeLightningInvoice = intent.getStringExtra(PaymentRequestActivity.EXTRA_LIGHTNING_INVOICE),
resumeNostrSecretHex = intent.getStringExtra(PaymentRequestActivity.EXTRA_NOSTR_SECRET_HEX),
resumeNostrNprofile = intent.getStringExtra(PaymentRequestActivity.EXTRA_NOSTR_NPROFILE),
checkoutBasketJson = intent.getStringExtra(PaymentRequestActivity.EXTRA_CHECKOUT_BASKET_JSON),
savedBasketId = intent.getStringExtra(PaymentRequestActivity.EXTRA_SAVED_BASKET_ID),
tipAmountSats = tipAmount,
tipPercentage = tipPercentage,
baseAmountSats = baseAmount,
baseFormattedAmount = baseFormatted,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.electricdreams.numo.payment

import android.content.Context
import android.util.Log
import com.electricdreams.numo.core.model.Amount
import com.electricdreams.numo.core.model.Amount.Currency
import com.electricdreams.numo.core.worker.BitcoinPriceWorker
import com.electricdreams.numo.feature.history.PaymentsHistoryActivity

class PendingPaymentRegistrar(
private val context: Context,
private val bitcoinPriceWorker: BitcoinPriceWorker?,
private val store: PendingPaymentStore = PendingPaymentStore.Default
) {
fun registerPendingPayment(
paymentAmount: Long,
formattedAmountString: String,
tipAmountSats: Long,
tipPercentage: Int,
baseAmountSats: Long,
baseFormattedAmount: String?,
checkoutBasketJson: String?,
savedBasketId: String?,
): String? {
val (entryUnit, enteredAmount) = determineEntryAmount(
formattedAmountString,
tipAmountSats,
baseAmountSats,
baseFormattedAmount
)

val bitcoinPrice = bitcoinPriceWorker?.getCurrentPrice()?.takeIf { it > 0 }

val pendingId = store.addPendingPayment(
context = context,
amount = paymentAmount,
entryUnit = entryUnit,
enteredAmount = enteredAmount,
bitcoinPrice = bitcoinPrice,
paymentRequest = null,
formattedAmount = formattedAmountString,
checkoutBasketJson = checkoutBasketJson,
basketId = savedBasketId,
tipAmountSats = tipAmountSats,
tipPercentage = tipPercentage,
)

Log.d(TAG, "✅ CREATED PENDING PAYMENT: id=$pendingId")
Log.d(TAG, " 💰 Total amount: $paymentAmount sats")
Log.d(TAG, " 📊 Base amount: $enteredAmount $entryUnit")
Log.d(TAG, " 💸 Tip: $tipAmountSats sats ($tipPercentage%)")
Log.d(TAG, " 🛒 Has basket: ${checkoutBasketJson != null}")
Log.d(TAG, " 📱 Formatted: $formattedAmountString")
return pendingId
}

private fun determineEntryAmount(
formattedAmountString: String,
tipAmountSats: Long,
baseAmountSats: Long,
baseFormattedAmount: String?,
): Pair<String, Long> {
if (tipAmountSats > 0 && baseAmountSats > 0) {
val parsedBase = baseFormattedAmount?.let { Amount.parse(it) }
if (parsedBase != null) {
val entryUnit = if (parsedBase.currency == Currency.BTC) "sat" else parsedBase.currency.name
return entryUnit to parsedBase.value
}
return "sat" to baseAmountSats
}

val parsedAmount = Amount.parse(formattedAmountString)
return if (parsedAmount != null) {
val entryUnit = if (parsedAmount.currency == Currency.BTC) "sat" else parsedAmount.currency.name
entryUnit to parsedAmount.value
} else {
val fallback = baseAmountSats.takeIf { it > 0 } ?: 0L
"sat" to fallback
}
}

companion object {
private const val TAG = "PendingPaymentRegistrar"
}
}

fun interface PendingPaymentStore {
fun addPendingPayment(
context: Context,
amount: Long,
entryUnit: String,
enteredAmount: Long,
bitcoinPrice: Double?,
paymentRequest: String?,
formattedAmount: String,
checkoutBasketJson: String?,
basketId: String?,
tipAmountSats: Long,
tipPercentage: Int,
): String?

object Default : PendingPaymentStore {
override fun addPendingPayment(
context: Context,
amount: Long,
entryUnit: String,
enteredAmount: Long,
bitcoinPrice: Double?,
paymentRequest: String?,
formattedAmount: String,
checkoutBasketJson: String?,
basketId: String?,
tipAmountSats: Long,
tipPercentage: Int,
): String? {
return PaymentsHistoryActivity.addPendingPayment(
context = context,
amount = amount,
entryUnit = entryUnit,
enteredAmount = enteredAmount,
bitcoinPrice = bitcoinPrice,
paymentRequest = paymentRequest,
formattedAmount = formattedAmount,
checkoutBasketJson = checkoutBasketJson,
basketId = basketId,
tipAmountSats = tipAmountSats,
tipPercentage = tipPercentage,
)
}
}
}
Loading
Loading