diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48f790ca..84111520 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } diff --git a/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt b/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt index f7bacb71..2ea7c06f 100644 --- a/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt +++ b/app/src/main/java/com/electricdreams/numo/PaymentRequestActivity.kt @@ -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 diff --git a/app/src/main/java/com/electricdreams/numo/payment/PaymentRequestHceManager.kt b/app/src/main/java/com/electricdreams/numo/payment/PaymentRequestHceManager.kt new file mode 100644 index 00000000..9be0ecc8 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/payment/PaymentRequestHceManager.kt @@ -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" + } +} diff --git a/app/src/main/java/com/electricdreams/numo/payment/PaymentRequestIntentData.kt b/app/src/main/java/com/electricdreams/numo/payment/PaymentRequestIntentData.kt new file mode 100644 index 00000000..76e6f7d5 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/payment/PaymentRequestIntentData.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/electricdreams/numo/payment/PendingPaymentRegistrar.kt b/app/src/main/java/com/electricdreams/numo/payment/PendingPaymentRegistrar.kt new file mode 100644 index 00000000..21a47b67 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/payment/PendingPaymentRegistrar.kt @@ -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 { + 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, + ) + } + } +} diff --git a/app/src/main/java/com/electricdreams/numo/payment/ui/PaymentRequestUi.kt b/app/src/main/java/com/electricdreams/numo/payment/ui/PaymentRequestUi.kt new file mode 100644 index 00000000..9cecbff4 --- /dev/null +++ b/app/src/main/java/com/electricdreams/numo/payment/ui/PaymentRequestUi.kt @@ -0,0 +1,90 @@ +package com.electricdreams.numo.payment.ui + +import android.app.Activity +import android.os.Handler +import android.os.Looper +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import com.electricdreams.numo.R +import com.electricdreams.numo.core.model.Amount +import com.electricdreams.numo.core.model.Amount.Currency +import com.electricdreams.numo.core.util.CurrencyManager +import com.electricdreams.numo.core.worker.BitcoinPriceWorker +import com.electricdreams.numo.ui.util.QrCodeGenerator + +class PaymentRequestUi( + private val activity: Activity, + private val bitcoinPriceWorker: BitcoinPriceWorker? +) { + private val handler = Handler(Looper.getMainLooper()) + + fun updateAmountDisplays( + largeAmountDisplay: TextView, + convertedAmountDisplay: TextView, + formattedAmount: String, + paymentAmount: Long + ) { + largeAmountDisplay.text = formattedAmount + val isBtcAmount = formattedAmount.startsWith("₿") + val hasBitcoinPrice = (bitcoinPriceWorker?.getCurrentPrice() ?: 0.0) > 0 + + if (!hasBitcoinPrice) { + convertedAmountDisplay.visibility = View.GONE + return + } + + if (isBtcAmount) { + val fiatValue = bitcoinPriceWorker?.satoshisToFiat(paymentAmount) ?: 0.0 + if (fiatValue > 0) { + val formattedFiat = bitcoinPriceWorker?.formatFiatAmount(fiatValue) + ?: CurrencyManager.getInstance(activity).formatCurrencyAmount(fiatValue) + convertedAmountDisplay.text = formattedFiat + convertedAmountDisplay.visibility = View.VISIBLE + } else { + convertedAmountDisplay.visibility = View.GONE + } + } else { + if (paymentAmount > 0) { + val formattedBtc = Amount(paymentAmount, Currency.BTC).toString() + convertedAmountDisplay.text = formattedBtc + convertedAmountDisplay.visibility = View.VISIBLE + } else { + convertedAmountDisplay.visibility = View.GONE + } + } + } + + fun showStatus(statusText: TextView, messageRes: Int) { + statusText.visibility = View.VISIBLE + statusText.setText(messageRes) + } + + fun showStatus(statusText: TextView, message: String) { + statusText.visibility = View.VISIBLE + statusText.text = message + } + + fun generateCashuQr(cashuQrImageView: ImageView, paymentRequest: String) { + try { + val qrBitmap = QrCodeGenerator.generate(paymentRequest, 512) + cashuQrImageView.setImageBitmap(qrBitmap) + } catch (e: Exception) { + Toast.makeText(activity, R.string.payment_request_status_error_qr, Toast.LENGTH_SHORT).show() + } + } + + fun generateLightningQr(lightningQrImageView: ImageView, bolt11: String) { + try { + val qrBitmap = QrCodeGenerator.generate(bolt11, 512) + lightningQrImageView.setImageBitmap(qrBitmap) + } catch (_: Exception) { + // ignored, spinner will hide later + } + } + + fun delayFinish(action: () -> Unit) { + handler.postDelayed(action, 3000) + } +} diff --git a/app/src/test/java/com/electricdreams/numo/payment/PaymentRequestIntentDataTest.kt b/app/src/test/java/com/electricdreams/numo/payment/PaymentRequestIntentDataTest.kt new file mode 100644 index 00000000..fa336ee2 --- /dev/null +++ b/app/src/test/java/com/electricdreams/numo/payment/PaymentRequestIntentDataTest.kt @@ -0,0 +1,52 @@ +package com.electricdreams.numo.payment + +import android.content.Intent +import com.electricdreams.numo.PaymentRequestActivity +import com.electricdreams.numo.core.model.Amount +import com.electricdreams.numo.feature.tips.TipSelectionActivity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PaymentRequestIntentDataTest { + + @Test + fun `fromIntent populates defaults when optional extras missing`() { + val amount = Amount(42_000, Amount.Currency.BTC) + val intent = Intent().apply { + putExtra(PaymentRequestActivity.EXTRA_PAYMENT_AMOUNT, amount.value) + } + + val data = PaymentRequestIntentData.fromIntent(intent) + + assertEquals(amount.value, data.paymentAmount) + assertEquals(amount.toString(), data.formattedAmount) + assertEquals(0, data.tipAmountSats) + assertEquals(0, data.tipPercentage) + assertTrue(!data.isResumingPayment) + } + + @Test + fun `fromIntent reads tip data when provided`() { + val intent = Intent().apply { + putExtra(PaymentRequestActivity.EXTRA_PAYMENT_AMOUNT, 1000L) + putExtra(PaymentRequestActivity.EXTRA_FORMATTED_AMOUNT, "₿1,000") + putExtra(TipSelectionActivity.EXTRA_TIP_AMOUNT_SATS, 200L) + putExtra(TipSelectionActivity.EXTRA_TIP_PERCENTAGE, 20) + putExtra(TipSelectionActivity.EXTRA_BASE_AMOUNT_SATS, 800L) + putExtra(TipSelectionActivity.EXTRA_BASE_FORMATTED_AMOUNT, "₿800") + putExtra(PaymentRequestActivity.EXTRA_RESUME_PAYMENT_ID, "pending123") + } + + val data = PaymentRequestIntentData.fromIntent(intent) + + assertEquals(200L, data.tipAmountSats) + assertEquals(20, data.tipPercentage) + assertEquals(800L, data.baseAmountSats) + assertEquals("₿800", data.baseFormattedAmount) + assertTrue(data.isResumingPayment) + } +} diff --git a/app/src/test/java/com/electricdreams/numo/payment/PendingPaymentRegistrarTest.kt b/app/src/test/java/com/electricdreams/numo/payment/PendingPaymentRegistrarTest.kt new file mode 100644 index 00000000..af570805 --- /dev/null +++ b/app/src/test/java/com/electricdreams/numo/payment/PendingPaymentRegistrarTest.kt @@ -0,0 +1,75 @@ +package com.electricdreams.numo.payment + +import android.content.Context +import com.electricdreams.numo.core.worker.BitcoinPriceWorker +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +import org.mockito.kotlin.whenever + +@RunWith(RobolectricTestRunner::class) +class PendingPaymentRegistrarTest { + + private lateinit var context: Context + private lateinit var bitcoinPriceWorker: BitcoinPriceWorker + + @Before + fun setup() { + context = mock() + bitcoinPriceWorker = mock() + } + + @Test + fun `registerPendingPayment stores parsed entry unit`() { + val fakeStore = FakeStore() + val registrar = PendingPaymentRegistrar(context, bitcoinPriceWorker, fakeStore) + whenever(bitcoinPriceWorker.getCurrentPrice()).thenReturn(50_000.0) + + val result = registrar.registerPendingPayment( + paymentAmount = 1_000L, + formattedAmountString = "₿1,000", + tipAmountSats = 0L, + tipPercentage = 0, + baseAmountSats = 0L, + baseFormattedAmount = null, + checkoutBasketJson = null, + savedBasketId = null, + ) + + assertEquals("pending-fake", result) + fakeStore.assertLastEntryUnit("sat") + } +} + + + private class FakeStore : PendingPaymentStore { + var lastEntryUnit: String? = null + 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? { + lastEntryUnit = entryUnit + return "pending-fake" + } + + fun assertLastEntryUnit(expected: String) { + if (lastEntryUnit != expected) { + throw AssertionError("Expected entry unit $expected but was $lastEntryUnit") + } + } + } diff --git a/refactor-plan.md b/refactor-plan.md new file mode 100644 index 00000000..e3cc124f --- /dev/null +++ b/refactor-plan.md @@ -0,0 +1,42 @@ +# PaymentRequestActivity Refactor Plan + +## Current Responsibilities +1. Intent parsing & pending payment creation +2. View initialization & tab UI wiring +3. Payment mode orchestration (Cashu/Nostr, Lightning, HCE) +4. Tip display logic +5. HCE service interaction +6. Payment result handling & history updates +7. Basket archiving & AutoWithdraw +8. Share & close interactions + +## Proposed Modules +1. **PaymentRequestIntentData** (data class) + - Parses intent extras (amount, tips, resume info, basket) + - Provides derived properties (hasTip, formatted base amount) + +2. **PaymentRequestInitializer** (helper) + - Handles BitcoinPriceWorker init + - Creates pending payment + stores ID + - Returns PendingPaymentContext used later + +3. **PaymentModeCoordinator** + - Owns PaymentTabManager, Nostr handler, Lightning handler, HCE switching + - Provides callbacks to Activity via interface + +4. **PaymentResultCoordinator** (wrap showPaymentSuccess etc.) + - Uses PaymentResultHandler for persistence + - Handles UI feedback, vibration, sound, finishing Activity, basket archiving + +## Activity After Refactor (~<300 lines) +- onCreate(): obtain intent data, init views, delegate to coordinators +- Implements small callback interfaces from coordinators +- Exposes minimal helper methods (update status text, show toasts) + +## Refactor Steps +1. Create `PaymentRequestIntentData` to move extras parsing logic. +2. Create `PendingPaymentRegistrar` to handle `createPendingPayment()`. +3. Create `PaymentHceManager` for HCE switching/setup. +4. Create `PaymentResultCoordinator` for success/error/cancel flows. +5. Update activity to use new classes; slim down to orchestration only. +6. Ensure unit responsibility per class < 300 lines.