From 82c048f6c07e33caaf9006cc478e79bccbd5b88f Mon Sep 17 00:00:00 2001 From: rasputin Date: Fri, 5 Jun 2026 15:43:10 +0200 Subject: [PATCH] feat: add nfc step 2 - receive with invoice --- app/src/main/AndroidManifest.xml | 23 +- .../bitcointxoko/gudariwallet/MainActivity.kt | 74 ++++- .../service/WalletNotificationService.kt | 15 +- .../gudariwallet/ui/WalletScreen.kt | 84 ++--- .../gudariwallet/ui/WalletViewModel.kt | 15 +- .../gudariwallet/ui/WalletViewModelFactory.kt | 5 - .../ui/clipboard/ClipboardViewModel.kt | 2 - .../gudariwallet/ui/nfc/NdefHceService.kt | 152 +++++++++ .../gudariwallet/ui/nfc/NfcViewModel.kt | 296 +++++++++--------- .../gudariwallet/ui/receive/QrDisplayCard.kt | 124 +++++--- .../gudariwallet/ui/receive/ReceiveScreen.kt | 96 ++++-- app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/apduservice.xml | 17 + 13 files changed, 588 insertions(+), 316 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NdefHceService.kt create mode 100644 app/src/main/res/xml/apduservice.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a22b927..368cc91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,11 +7,12 @@ + - + + android:foregroundServiceType="specialUse" + android:exported="false"> + + + + + + + + + diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt index ccdc842..f2300c3 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt @@ -1,8 +1,11 @@ package com.bitcointxoko.gudariwallet import android.Manifest +import android.app.PendingIntent import android.content.ActivityNotFoundException import android.content.Intent +import android.nfc.NfcAdapter +import android.nfc.Tag import android.os.Build import android.os.Bundle import android.os.PowerManager @@ -25,6 +28,7 @@ import com.bitcointxoko.gudariwallet.ui.OnboardingScreen import com.bitcointxoko.gudariwallet.ui.WalletScreen import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory +import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.theme.GudariWalletTheme import com.bitcointxoko.gudariwallet.util.NotificationHelper import com.bitcointxoko.gudariwallet.util.IntentUtils @@ -40,13 +44,30 @@ class MainActivity : AppCompatActivity() { WalletViewModelFactory(application) } + // ── NFC ────────────────────────────────────────────────────────────────── + + val nfcVm: NfcViewModel by viewModels() + + private var nfcAdapter: NfcAdapter? = null + + /** Pending intent used by foreground dispatch — routes tag intents back here. */ + private val nfcPendingIntent: PendingIntent by lazy { + PendingIntent.getActivity( + this, 0, + Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_MUTABLE + ) + } + + // ── Existing state ─────────────────────────────────────────────────────── + // Holds a paymentHash that arrived via notification tap. // WalletScreen observes this and navigates to the detail screen. // MutableState so Compose recomposes when it changes. private val pendingPaymentHash = mutableStateOf(null) // Holds a payment URI that arrived via deep link / intent (BTCPay, external apps). - private val pendingPaymentUri = mutableStateOf(null) + private val pendingPaymentUri = mutableStateOf(null) private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* informational */ } @@ -73,10 +94,14 @@ class MainActivity : AppCompatActivity() { NotificationHelper.createChannels(applicationContext) // Read hash from the intent that launched the activity (cold start from notification) - pendingPaymentHash.value = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH) + pendingPaymentHash.value = + intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH) // Read payment URI from the intent that launched the activity (cold start from deep link) pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent) + // Initialise NFC adapter (null on devices without NFC — handled gracefully) + nfcAdapter = NfcAdapter.getDefaultAdapter(this) + enableEdgeToEdge() setContent { @@ -94,6 +119,7 @@ class MainActivity : AppCompatActivity() { } WalletScreen( vm = vm, + nfcVm = nfcVm, pendingPaymentHash = pendingPaymentHash, pendingPaymentUri = pendingPaymentUri ) @@ -105,26 +131,55 @@ class MainActivity : AppCompatActivity() { } } } - } - // Called when the activity is already running and a notification is tapped + // ── NFC foreground dispatch ─────────────────────────────────────────────── + + override fun onResume() { + super.onResume() + // Give this activity first pick of all NFC tag intents while foregrounded. + // Passing null for techLists and intentFilters means we intercept every tag type. + nfcAdapter?.enableForegroundDispatch( + this, + nfcPendingIntent, + null, // intercept all intent filters + null // intercept all tech lists + ) + } + + override fun onPause() { + super.onPause() + nfcAdapter?.disableForegroundDispatch(this) + } + + // ── Intent routing ──────────────────────────────────────────────────────── + + // Called when the activity is already running and a notification is tapped, + // a deep link arrives, or an NFC tag is discovered while foregrounded. // (FLAG_ACTIVITY_SINGLE_TOP prevents a new instance being created) override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + + // Notification tap → navigate to payment detail val hash = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH) if (hash != null) { pendingPaymentHash.value = hash } + + // Deep link / external app URI val uri = IntentUtils.extractPaymentUri(intent) if (uri != null) { pendingPaymentUri.value = uri } + + // NFC tag discovered → hand off to NfcViewModel for reading + val tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + if (tag != null) { + nfcVm.onTagDiscovered(tag) + } } - - // ── Permission / battery helpers (unchanged) ────────────────────────────── private fun requestNotificationPermissionIfNeeded() { @@ -154,9 +209,10 @@ class MainActivity : AppCompatActivity() { } private fun openBatterySettings() { - val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = "package:$packageName".toUri() - } + val appDetailsIntent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = "package:$packageName".toUri() + } if (tryStartActivity(appDetailsIntent)) return tryStartActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt index 067962b..38d7fed 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt @@ -104,17 +104,12 @@ class WalletNotificationService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { // Step 1: Call startForeground immediately — Android requires this within // 5 seconds of startForegroundService() or the app is ANR'd. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NotificationConstants.NOTIF_ID_SERVICE, - NotificationHelper.buildServiceNotification(this), - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34 + startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { - startForeground( - NotificationConstants.NOTIF_ID_SERVICE, - NotificationHelper.buildServiceNotification(this) - ) + startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this)) } // Step 2: Open the WebSocket. If already open (e.g. service restarted diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index 7375523..ad1b100 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -58,6 +58,7 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen +import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.util.SendInputType @@ -67,7 +68,7 @@ import kotlinx.coroutines.delay sealed class TabItem(val route: String, val label: String, val icon: ImageVector) { data object Receive : TabItem("receive", "Receive", Icons.Filled.QrCode) - data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send) + data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send) } private const val ROUTE_HOME = "home" @@ -78,6 +79,7 @@ private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}" @Composable fun WalletScreen( vm : WalletViewModel, + nfcVm : NfcViewModel, // ← new pendingPaymentHash: MutableState, pendingPaymentUri : MutableState ) { @@ -85,10 +87,10 @@ fun WalletScreen( val context = LocalContext.current val historyFactory = remember { HistoryViewModelFactory( - repo = vm.repo, - aliasRepo = vm.aliasRepo, - paymentCache = vm.paymentCache, - fiatCurrency = vm.selectedCurrency, + repo = vm.repo, + aliasRepo = vm.aliasRepo, + paymentCache = vm.paymentCache, + fiatCurrency = vm.selectedCurrency, fiatSatsPerUnit = vm.fiatSatsPerUnit ) } @@ -143,7 +145,7 @@ fun WalletScreen( LaunchedEffect(intentUri) { if (intentUri != null) { Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri") - pendingPaymentUri.value = null // consume immediately + pendingPaymentUri.value = null vm.scan(intentUri) navController.navigate(TabItem.Send.route) { launchSingleTop = true @@ -151,9 +153,9 @@ fun WalletScreen( } } + // ── Clipboard offer ─────────────────────────────────────────────────────── val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle() - // Auto-dismiss banner after 8 seconds LaunchedEffect(clipboardOffer) { if (clipboardOffer is ClipboardOfferState.Detected) { delay(8_000) @@ -161,33 +163,13 @@ fun WalletScreen( } } - // ── NFC reader mode — register on composition, unregister on disposal ──────── - val nfcAdapter = remember { - android.nfc.NfcAdapter.getDefaultAdapter(context) - } + // ── NFC offer (tag read while app is foregrounded) ──────────────────────── + val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle() - DisposableEffect(Unit) { - val activity = context as MainActivity - nfcAdapter?.enableReaderMode( - activity, - { tag -> vm.onNfcTagDiscovered(tag) }, // runs on NFC binder thread - android.nfc.NfcAdapter.FLAG_READER_NFC_A or - android.nfc.NfcAdapter.FLAG_READER_NFC_B or - android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK.inv(), // keep NDEF - null - ) - onDispose { - nfcAdapter?.disableReaderMode(activity) - } - } - - val nfcOffer by vm.nfcOfferState.collectAsStateWithLifecycle() - - // Auto-dismiss NFC banner after 8 seconds (same as clipboard) LaunchedEffect(nfcOffer) { if (nfcOffer is NfcOfferState.Detected) { delay(8_000) - vm.dismissNfcOffer() + nfcVm.dismissNfcOffer() } } @@ -304,7 +286,8 @@ fun WalletScreen( } composable(TabItem.Receive.route) { ReceiveScreen( - vm, + vm = vm, + nfcVm = nfcVm, // ← new onNavigateToPaymentDetail = { checkingId -> navController.navigate("payment_detail/$checkingId") } @@ -339,9 +322,8 @@ fun WalletScreen( // History — full screen, no bottom chrome, close → home composable( ROUTE_HISTORY, - enterTransition = { fadeIn(animationSpec = tween(150)) }, - popExitTransition = { fadeOut(animationSpec = tween(150)) } - + enterTransition = { fadeIn(animationSpec = tween(150)) }, + popExitTransition = { fadeOut(animationSpec = tween(150)) } ) { HistoryScreen( vm = historyVm, @@ -371,8 +353,8 @@ fun WalletScreen( if (payment != null) { PaymentDetailScreen( payment = payment, - vm = historyVm, - onBack = { navController.popBackStack() } + vm = historyVm, + onBack = { navController.popBackStack() } ) } else { LaunchedEffect(checkingId) { historyVm.refresh() } @@ -393,13 +375,13 @@ fun WalletScreen( exit = slideOutVertically(targetOffsetY = { -it }), modifier = Modifier .align(Alignment.TopCenter) - .zIndex(1f) + .zIndex(2f) // above NFC banner .fillMaxWidth() .statusBarsPadding() ) { if (clipboardOffer is ClipboardOfferState.Detected) { - val offer = clipboardOffer as ClipboardOfferState.Detected - val label = when (offer.inputType) { + val offer = clipboardOffer as ClipboardOfferState.Detected + val label = when (offer.inputType) { is SendInputType.Bolt11 -> "Invoice" is SendInputType.Lnurl -> "LNURL" is SendInputType.LightningAddress -> "Lightning Address" @@ -445,22 +427,23 @@ fun WalletScreen( } } } - // ── NFC banner — floats below clipboard banner, slides in from top ──────────── + + // ── NFC banner — floats over content, slides in from top ────────── + // Shown when an NFC tag is tapped while the app is foregrounded. + // Mirrors the clipboard banner pattern exactly. AnimatedVisibility( visible = nfcOffer is NfcOfferState.Detected, enter = slideInVertically(initialOffsetY = { -it }), exit = slideOutVertically(targetOffsetY = { -it }), modifier = Modifier .align(Alignment.TopCenter) - .zIndex(1f) + .zIndex(1f) // below clipboard banner if both show .fillMaxWidth() .statusBarsPadding() - // push down if clipboard banner is also showing - .padding(top = if (clipboardOffer is ClipboardOfferState.Detected) 64.dp else 0.dp) ) { if (nfcOffer is NfcOfferState.Detected) { - val offer = nfcOffer as NfcOfferState.Detected - val label = when (offer.inputType) { + val offer = nfcOffer as NfcOfferState.Detected + val label = when (offer.inputType) { is SendInputType.Bolt11 -> "Invoice" is SendInputType.Lnurl -> "LNURL" is SendInputType.LightningAddress -> "Lightning Address" @@ -471,15 +454,12 @@ fun WalletScreen( else -> "Tap to pay" } Surface( - color = MaterialTheme.colorScheme.secondaryContainer, // distinct from clipboard's primaryContainer + color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier .fillMaxWidth() .clickable { - vm.dismissNfcOffer() + nfcVm.dismissNfcOffer() vm.scan(offer.raw) - // scan() already emits navigateToReceive for LNURL-withdraw, - // so we only need to navigate to Send for everything else. - // The existing navigateToReceive LaunchedEffect handles BoltCard. navController.navigate(TabItem.Send.route) { launchSingleTop = true } @@ -489,7 +469,7 @@ fun WalletScreen( modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon(Icons.Filled.Nfc, contentDescription = null) + Icon(Icons.Default.Nfc, contentDescription = null) Spacer(Modifier.width(12.dp)) Column(Modifier.weight(1f)) { Text( @@ -502,7 +482,7 @@ fun WalletScreen( color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) ) } - IconButton(onClick = { vm.dismissNfcOffer() }) { + IconButton(onClick = { nfcVm.dismissNfcOffer() }) { Icon(Icons.Default.Close, contentDescription = "Dismiss") } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt index c04b937..3b8332e 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -17,7 +17,6 @@ import com.bitcointxoko.gudariwallet.data.LightningAddressStore import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel -import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import kotlinx.coroutines.flow.SharingStarted @@ -35,8 +34,7 @@ class WalletViewModel( val fiatVm : FiatViewModel, val receiveVm : ReceiveViewModel, val sendVm : SendViewModel, - val clipboardVm : ClipboardViewModel, - val nfcVm : NfcViewModel + val clipboardVm : ClipboardViewModel ) : ViewModel() { val balanceState: StateFlow get() = balanceVm.balanceState val balanceHidden: StateFlow get() = fiatVm.balanceHidden @@ -77,17 +75,6 @@ class WalletViewModel( fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text) fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer() - val nfcOfferState: StateFlow = nfcVm.nfcOfferState - - fun onNfcTagDiscovered(tag: android.nfc.Tag) = nfcVm.onTagDiscovered(tag) - @VisibleForTesting - fun onNfcTagRead(text: String?) = nfcVm.onNfcTagRead(text) - fun dismissNfcOffer() = nfcVm.dismissNfcOffer() - val nfcBroadcastState: StateFlow get() = nfcVm.broadcastState - fun startNfcBroadcast(payload: String) = nfcVm.startBroadcast(payload) - fun cancelNfcBroadcast() = nfcVm.cancelBroadcast() - fun dismissNfcBroadcastResult() = nfcVm.dismissBroadcastResult() - init { observePaymentEvents() fetchLightningAddress() diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index 61f0e0a..f1632cd 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -50,10 +50,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 }, ownAddress = { lnAddressStore.currentAddress } ) - val nfcVm = NfcViewModel( - ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 }, - ownAddress = { lnAddressStore.currentAddress } - ) balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() } @@ -68,7 +64,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F receiveVm = receiveVm, sendVm = sendVm, clipboardVm = clipboardVm, - nfcVm = nfcVm, lnAddressStore = lnAddressStore, ) as T } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/clipboard/ClipboardViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/clipboard/ClipboardViewModel.kt index 3c91375..c9ff0ad 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/clipboard/ClipboardViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/clipboard/ClipboardViewModel.kt @@ -3,12 +3,10 @@ package com.bitcointxoko.gudariwallet.ui.clipboard import android.util.Log import androidx.lifecycle.ViewModel import com.bitcointxoko.gudariwallet.ui.ClipboardOfferState -import com.bitcointxoko.gudariwallet.ui.ReceiveState import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.StateFlow as SF private const val TAG = "ClipboardViewModel" diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NdefHceService.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NdefHceService.kt new file mode 100644 index 0000000..0aeced1 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NdefHceService.kt @@ -0,0 +1,152 @@ +package com.bitcointxoko.gudariwallet.ui.nfc + +import android.nfc.cardemulation.HostApduService +import android.os.Bundle +import android.util.Log + +/** + * HCE service that emulates an NFC Forum Type 4 Tag containing an NDEF + * message. The NDEF message holds a URI record (lightning: or bitcoin: URI) + * set by NfcViewModel via the companion-object singleton slot. + * + * Protocol reference: NFC Forum Type 4 Tag Operation Specification. + * The reader performs: + * 1. SELECT NDEF Application (AID D2760000850101) + * 2. SELECT Capability Container file (file ID 0xE103) + * 3. READ BINARY of CC file + * 4. SELECT NDEF file (file ID from CC) + * 5. READ BINARY of NDEF file (length prefix + NDEF message bytes) + */ +class NdefHceService : HostApduService() { + + companion object { + private const val TAG = "NdefHceService" + + // ── APDU status words ──────────────────────────────────────────────── + private val SW_OK = byteArrayOf(0x90.toByte(), 0x00) + private val SW_FILE_NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte()) + private val SW_UNKNOWN = byteArrayOf(0x6F.toByte(), 0x00) + + // ── File IDs ───────────────────────────────────────────────────────── + private val CC_FILE_ID = byteArrayOf(0xE1.toByte(), 0x03.toByte()) + private val NDEF_FILE_ID = byteArrayOf(0xE1.toByte(), 0x04.toByte()) + + // ── NDEF Application AID ───────────────────────────────────────────── + private val NDEF_AID = byteArrayOf( + 0xD2.toByte(), 0x76.toByte(), 0x00.toByte(), 0x00.toByte(), + 0x85.toByte(), 0x01.toByte(), 0x01.toByte() + ) + + // ── Capability Container (CC) file ─────────────────────────────────── + // Standard 15-byte CC for a Type 4 Tag: + // Bytes 0-1 : CC length (0x000F = 15) + // Byte 2 : Mapping version (0x20 = v2.0) + // Bytes 3-4 : Max R-APDU data size (0x007F = 127) + // Bytes 5-6 : Max C-APDU data size (0x007F = 127) + // Bytes 7-8 : NDEF File Control TLV tag+length (0x04, 0x06) + // Bytes 9-10: NDEF file ID (0xE104) + // Bytes 11-12: Max NDEF file size (0x0400 = 1024) + // Byte 13 : Read access (0x00 = open) + // Byte 14 : Write access (0xFF = no write) + private val CC_FILE = byteArrayOf( + 0x00, 0x0F, + 0x20, + 0x00, 0x7F, + 0x00, 0x7F, + 0x04, 0x06, + 0xE1.toByte(), 0x04, + 0x04, 0x00, + 0x00, + 0xFF.toByte() + ) + + // ── Shared NDEF payload slot ───────────────────────────────────────── + // Written by NfcViewModel, read by processCommandApdu(). + // Volatile is sufficient — single writer, single reader on binder thread. + @Volatile + var ndefFileBytes: ByteArray? = null + } + + // Which file is currently selected (null = NDEF app selected, no file yet) + private var selectedFileId: ByteArray? = null + + override fun processCommandApdu(apdu: ByteArray, extras: Bundle?): ByteArray { + Log.d(TAG, "APDU IN: ${apdu.toHex()}") + + if (apdu.size < 4) return SW_UNKNOWN + + val ins = apdu[1] + + return when { + // ── SELECT (INS = 0xA4) ────────────────────────────────────────── + ins == 0xA4.toByte() -> handleSelect(apdu) + + // ── READ BINARY (INS = 0xB0) ───────────────────────────────────── + ins == 0xB0.toByte() -> handleReadBinary(apdu) + + else -> SW_UNKNOWN + }.also { Log.d(TAG, "APDU OUT: ${it.toHex()}") } + } + + private fun handleSelect(apdu: ByteArray): ByteArray { + if (apdu.size < 5) return SW_UNKNOWN + val dataLen = apdu[4].toInt() and 0xFF + if (apdu.size < 5 + dataLen) return SW_UNKNOWN + val data = apdu.copyOfRange(5, 5 + dataLen) + + return when { + data.contentEquals(NDEF_AID) -> { + selectedFileId = null // app selected, no file yet + Log.d(TAG, "SELECT NDEF Application OK") + SW_OK + } + data.contentEquals(CC_FILE_ID) -> { + selectedFileId = CC_FILE_ID + Log.d(TAG, "SELECT CC file OK") + SW_OK + } + data.contentEquals(NDEF_FILE_ID) -> { + selectedFileId = NDEF_FILE_ID + Log.d(TAG, "SELECT NDEF file OK") + SW_OK + } + else -> { + Log.w(TAG, "SELECT unknown file: ${data.toHex()}") + SW_FILE_NOT_FOUND + } + } + } + + private fun handleReadBinary(apdu: ByteArray): ByteArray { + if (apdu.size < 5) return SW_UNKNOWN + val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF) + val length = apdu[4].toInt() and 0xFF + + val fileData: ByteArray = when { + selectedFileId != null && selectedFileId!!.contentEquals(CC_FILE_ID) -> + CC_FILE + selectedFileId != null && selectedFileId!!.contentEquals(NDEF_FILE_ID) -> { + ndefFileBytes ?: run { + Log.w(TAG, "READ BINARY: no NDEF data loaded") + return SW_FILE_NOT_FOUND + } + } + else -> return SW_FILE_NOT_FOUND + } + + if (offset >= fileData.size) return SW_FILE_NOT_FOUND + val end = minOf(offset + length, fileData.size) + val chunk = fileData.copyOfRange(offset, end) + return chunk + SW_OK + } + + override fun onDeactivated(reason: Int) { + Log.d(TAG, "HCE deactivated, reason=$reason") + selectedFileId = null + } + + // ── Helper ─────────────────────────────────────────────────────────────── + private fun ByteArray.toHex(): String = + joinToString("") { "%02X".format(it) } +} + diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt index 3790d9c..3ed09f8 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt @@ -1,13 +1,17 @@ -// ui/nfc/NfcViewModel.kt package com.bitcointxoko.gudariwallet.ui.nfc +import android.content.ComponentName +import android.content.Context +import android.nfc.NfcAdapter import android.nfc.NdefMessage import android.nfc.NdefRecord import android.nfc.Tag +import android.nfc.tech.IsoDep import android.nfc.tech.Ndef import android.nfc.tech.NdefFormatable import android.util.Log import androidx.lifecycle.ViewModel +import com.bitcointxoko.gudariwallet.ui.nfc.NdefHceService import com.bitcointxoko.gudariwallet.ui.NfcOfferState import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputType @@ -16,129 +20,60 @@ import kotlinx.coroutines.flow.StateFlow private const val TAG = "NfcViewModel" -class NfcViewModel( - // Same lambda pattern as ClipboardViewModel — no peer VM dependencies. - private val ownInvoice : () -> String?, - private val ownAddress : () -> String?, -) : ViewModel() { +class NfcViewModel : ViewModel() { - // ── Broadcast state ─────────────────────────────────────────────────────────── + // ── NFC receive (HCE emulation) ────────────────────────────────────────── - sealed interface NfcBroadcastState { - data object Idle : NfcBroadcastState - data object WaitingForTap : NfcBroadcastState // button pressed, awaiting tag - data object Success : NfcBroadcastState - data class Error(val message: String) : NfcBroadcastState - } - private val _nfcOfferState = MutableStateFlow(NfcOfferState.None) - val nfcOfferState: StateFlow = _nfcOfferState + private val _isNfcEmulating = MutableStateFlow(false) + val isNfcEmulating: StateFlow = _isNfcEmulating - private val _broadcastState = MutableStateFlow(NfcBroadcastState.Idle) - val broadcastState: StateFlow = _broadcastState - private var pendingBroadcastPayload: String? = null - - fun startBroadcast(payload: String) { - pendingBroadcastPayload = payload - _broadcastState.value = NfcBroadcastState.WaitingForTap - Log.d(TAG, "startBroadcast: waiting for tag tap, payload=$payload") - } - - fun cancelBroadcast() { - pendingBroadcastPayload = null - _broadcastState.value = NfcBroadcastState.Idle - } - - fun dismissBroadcastResult() { - _broadcastState.value = NfcBroadcastState.Idle + /** + * Start emulating an NFC tag containing [uri] (e.g. "lightning:lnbc..."). + * Builds the NDEF file bytes and loads them into NdefHceService. + */ + fun startEmulating(uri: String) { + Log.d(TAG, "startEmulating: $uri") + NdefHceService.ndefFileBytes = buildNdefFileBytes(uri) + _isNfcEmulating.value = true } /** - * Called from onTagDiscovered. If a broadcast is pending, write to the tag - * instead of treating it as an incoming payment offer. - * Returns true if the tag was consumed for writing (caller should not process - * it as an incoming offer). + * Stop emulating — clears the NDEF payload from the HCE service. */ - fun tryWriteToTag(tag: Tag): Boolean { - val payload = pendingBroadcastPayload ?: return false - - val record = if (payload.startsWith("http://") || payload.startsWith("https://") - || payload.startsWith("lnurl") || payload.startsWith("bitcoin:") - ) { - // URI record - NdefRecord.createUri(payload) - } else { - // Text record (bolt11, lightning address, etc.) - NdefRecord.createTextRecord("en", payload) - } - - val message = NdefMessage(arrayOf(record)) - - return try { - val ndef = Ndef.get(tag) - if (ndef != null) { - ndef.connect() - if (!ndef.isWritable) { - _broadcastState.value = NfcBroadcastState.Error("Tag is read-only") - return true - } - if (ndef.maxSize < message.toByteArray().size) { - _broadcastState.value = NfcBroadcastState.Error("Tag too small") - return true - } - ndef.writeNdefMessage(message) - ndef.close() - } else { - // Try to format an unformatted tag - val formatable = NdefFormatable.get(tag) - ?: run { - _broadcastState.value = NfcBroadcastState.Error("Tag not NDEF compatible") - return true - } - formatable.connect() - formatable.format(message) - formatable.close() - } - pendingBroadcastPayload = null - _broadcastState.value = NfcBroadcastState.Success - Log.d(TAG, "tryWriteToTag: write successful") - true - } catch (e: Exception) { - Log.e(TAG, "tryWriteToTag: write failed", e) - _broadcastState.value = NfcBroadcastState.Error(e.message ?: "Write failed") - true - } + fun stopEmulating() { + Log.d(TAG, "stopEmulating") + NdefHceService.ndefFileBytes = null + _isNfcEmulating.value = false } - // ── Called from WalletScreen's NFC reader-mode callback ────────────────── + override fun onCleared() { + super.onCleared() + stopEmulating() + } + + // ── NFC send (tag reading) ─────────────────────────────────────────────── + + private val _nfcOfferState = MutableStateFlow(NfcOfferState.None) + val nfcOfferState: StateFlow = _nfcOfferState + + /** + * Called from MainActivity.onNewIntent when an NFC tag is discovered. + * Reads the NDEF message from the tag and checks if it contains a + * recognisable payment URI. + */ fun onTagDiscovered(tag: Tag) { - // If a broadcast is pending, write to the tag and stop - if (tryWriteToTag(tag)) return - // Otherwise, decode and offer as incoming payment - val raw = decodeNdefTag(tag) - Log.d(TAG, "onTagDiscovered decoded: $raw") - onNfcTagRead(raw) - } + Log.d(TAG, "onTagDiscovered: ${tag.id?.toHex()}") - fun onNfcTagRead(text: String?) { - val trimmed = text?.trim().takeIf { !it.isNullOrBlank() } ?: return - - // Guard: ignore our own invoice - val invoice = ownInvoice() - if (invoice != null && trimmed.equals(invoice, ignoreCase = true)) { - Log.d(TAG, "Skipping own invoice") - return - } - - // Guard: ignore our own lightning address - val address = ownAddress() - if (address != null && trimmed.equals(address, ignoreCase = true)) { - Log.d(TAG, "Skipping own lightning address") + val text = readNdefText(tag) ?: run { + Log.w(TAG, "Could not read NDEF from tag") return } + Log.d(TAG, "Tag NDEF text: $text") + val trimmed = text.trim() val type = SendInputDetector.detect(trimmed) if (type == SendInputType.Unknown) { - Log.d(TAG, "Unknown input type, ignoring") + Log.d(TAG, "Tag content not a recognised payment URI") return } @@ -149,55 +84,118 @@ class NfcViewModel( _nfcOfferState.value = NfcOfferState.None } - // ── NDEF decoding ───────────────────────────────────────────────────────── - private fun decodeNdefTag(tag: Tag): String? { - val ndef = Ndef.get(tag) ?: return null - return try { + // ── NDEF reading ───────────────────────────────────────────────────────── + + private fun readNdefText(tag: Tag): String? { + // Try standard NDEF first (covers Type 1–5 tags, NTAG21x, etc.) + Ndef.get(tag)?.use { ndef -> ndef.connect() - val message = ndef.ndefMessage ?: return null - val record = message.records.firstOrNull() ?: return null - decodeNdefRecord(record) - } catch (e: Exception) { - Log.e(TAG, "NDEF read failed", e) - null - } finally { - runCatching { ndef.close() } + val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return null + return extractUri(msg) } - } - private fun decodeNdefRecord(record: NdefRecord): String? = when (record.tnf) { - NdefRecord.TNF_WELL_KNOWN -> when { - // URI record (https://..., lnurl:..., bitcoin:..., etc.) - record.type.contentEquals(NdefRecord.RTD_URI) -> - record.toUri()?.toString() - - // Plain text record (lnbc..., user@domain.com, etc.) - record.type.contentEquals(NdefRecord.RTD_TEXT) -> - decodeTextRecord(record.payload) - - else -> null + // Try NdefFormatable (blank tags — nothing to read) + // Try IsoDep last (Type 4 / ISO-DEP tags like Bolt Cards) + IsoDep.get(tag)?.use { isoDep -> + isoDep.connect() + isoDep.timeout = 3000 + val msg = readNdefFromIsoDep(isoDep) ?: return null + return extractUri(msg) } - // Absolute URI (some tags encode this way) - NdefRecord.TNF_ABSOLUTE_URI -> - String(record.payload, Charsets.UTF_8) - else -> null + return null } /** - * NDEF Text record payload layout: - * byte 0 : status byte (bit7 = UTF-16 flag, bits 5-0 = lang code length) - * bytes 1..n : language code (e.g. "en") - * bytes n+1.. : the actual text + * Reads an NDEF message from an ISO-DEP (Type 4) tag by replaying the + * same APDU sequence our HCE service responds to. */ - private fun decodeTextRecord(payload: ByteArray): String? { - if (payload.isEmpty()) return null - val statusByte = payload[0].toInt() and 0xFF - val isUtf16 = (statusByte and 0x80) != 0 - val langLength = statusByte and 0x3F - val textStart = 1 + langLength - if (textStart > payload.size) return null - val charset = if (isUtf16) Charsets.UTF_16 else Charsets.UTF_8 - return String(payload, textStart, payload.size - textStart, charset) + private fun readNdefFromIsoDep(isoDep: IsoDep): NdefMessage? { + fun ByteArray.sw(): Int = + ((this[size - 2].toInt() and 0xFF) shl 8) or (this[size - 1].toInt() and 0xFF) + fun ByteArray.data(): ByteArray = copyOfRange(0, size - 2) + fun ByteArray.isOk() = sw() == 0x9000 + + // 1. SELECT NDEF Application + val selectApp = byteArrayOf( + 0x00, 0xA4.toByte(), 0x04, 0x00, 0x07, + 0xD2.toByte(), 0x76, 0x00, 0x00, 0x85.toByte(), 0x01, 0x01, + 0x00 + ) + if (!isoDep.transceive(selectApp).isOk()) return null + + // 2. SELECT CC file + val selectCc = byteArrayOf(0x00, 0xA4.toByte(), 0x00, 0x0C, 0x02, 0xE1.toByte(), 0x03) + if (!isoDep.transceive(selectCc).isOk()) return null + + // 3. READ CC (15 bytes) + val readCc = byteArrayOf(0x00, 0xB0.toByte(), 0x00, 0x00, 0x0F) + val ccResp = isoDep.transceive(readCc) + if (!ccResp.isOk() || ccResp.size < 17) return null + val cc = ccResp.data() + + // Parse NDEF file ID and max size from CC + val ndefFileId = cc.copyOfRange(9, 11) + val maxNdefSize = ((cc[11].toInt() and 0xFF) shl 8) or (cc[12].toInt() and 0xFF) + + // 4. SELECT NDEF file + val selectNdef = byteArrayOf(0x00, 0xA4.toByte(), 0x00, 0x0C, 0x02) + ndefFileId + if (!isoDep.transceive(selectNdef).isOk()) return null + + // 5. READ NDEF length (first 2 bytes) + val readLen = byteArrayOf(0x00, 0xB0.toByte(), 0x00, 0x00, 0x02) + val lenResp = isoDep.transceive(readLen) + if (!lenResp.isOk()) return null + val ndefLen = ((lenResp[0].toInt() and 0xFF) shl 8) or (lenResp[1].toInt() and 0xFF) + if (ndefLen == 0 || ndefLen > maxNdefSize) return null + + // 6. READ NDEF data + val readData = byteArrayOf( + 0x00, 0xB0.toByte(), + 0x00, 0x02, // offset 2 (skip the 2-byte length) + ndefLen.coerceAtMost(0xFF).toByte() + ) + val dataResp = isoDep.transceive(readData) + if (!dataResp.isOk()) return null + + return runCatching { NdefMessage(dataResp.data()) }.getOrNull() } + + // ── NDEF helpers ───────────────────────────────────────────────────────── + + private fun extractUri(msg: NdefMessage): String? { + for (record in msg.records) { + val uri = record.toUri() + if (uri != null) return uri.toString() + + // Fallback: plain text record + if (record.tnf == NdefRecord.TNF_WELL_KNOWN && + record.type.contentEquals(NdefRecord.RTD_TEXT) + ) { + val payload = record.payload + val langLen = payload[0].toInt() and 0x3F + return String(payload, 1 + langLen, payload.size - 1 - langLen, Charsets.UTF_8) + } + } + return null + } + + // ── NDEF file builder (for HCE emulation) ──────────────────────────────── + + /** + * Builds the raw bytes of the NDEF file as served by the HCE service. + * Layout: [2-byte NDEF length (big-endian)] [NDEF message bytes] + */ + private fun buildNdefFileBytes(uri: String): ByteArray { + val record = NdefRecord.createUri(uri) + val ndefMessage = NdefMessage(record) + val msgBytes = ndefMessage.toByteArray() + val len = msgBytes.size + return byteArrayOf( + ((len shr 8) and 0xFF).toByte(), + (len and 0xFF).toByte() + ) + msgBytes + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02X".format(it) } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt index ab8ed22..d207a0a 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt @@ -2,55 +2,57 @@ package com.bitcointxoko.gudariwallet.ui.receive import android.content.ClipData import android.graphics.Bitmap +import android.graphics.Color import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Nfc import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material.icons.outlined.Nfc +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap +import androidx.core.graphics.set import com.bitcointxoko.gudariwallet.util.IntentUtils import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter import kotlinx.coroutines.launch -import android.graphics.Color -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.material.icons.filled.Nfc -import androidx.compose.material3.IconButton -import androidx.compose.ui.Alignment -import androidx.compose.ui.layout.ContentScale -import androidx.core.graphics.createBitmap -import androidx.core.graphics.set - /** - * QR image followed by Copy / Share / Brightness action row. + * QR image followed by Share / Copy / Brightness / NFC icon-button row. * - * @param content What to encode into QR - * @param contentDescription Accessibility label for the image. - * @param clipLabel Label used when writing to the clipboard (e.g. "Lightning Address"). - * @param shareTitle Chooser title for the share intent. - * @param textToCopy The raw string placed on the clipboard and shared. - * @param brightnessOn Current brightness-boost state. - * @param onToggleBrightness Called when the brightness button is tapped. + * @param content What to encode into the QR code. + * @param contentDescription Accessibility label for the QR image. + * @param clipLabel Label used when writing to the clipboard. + * @param shareTitle Chooser title for the share intent. + * @param textToCopy The raw string placed on the clipboard and shared. + * @param brightnessOn Current brightness-boost state. + * @param onToggleBrightness Called when the brightness button is tapped. + * @param isNfcEmulating Current NFC emulation state (null = hide NFC button). + * @param onToggleNfc Called when the NFC button is tapped. + * @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired). */ @Composable internal fun QrDisplayCard( @@ -62,11 +64,12 @@ internal fun QrDisplayCard( textToCopy : String, brightnessOn : Boolean, onToggleBrightness : () -> Unit, - onNfcBroadcast : (() -> Unit)? = null, + isNfcEmulating : Boolean? = null, // null → no NFC button shown + onToggleNfc : (() -> Unit)? = null, + nfcEnabled : Boolean = true, qrModifier : Modifier = Modifier.size(260.dp) ) { - val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() } - + val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() } val context = LocalContext.current val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() @@ -76,6 +79,7 @@ internal fun QrDisplayCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp) ) { + // ── QR image ───────────────────────────────────────────────────────── Image( bitmap = bitmap, contentDescription = contentDescription, @@ -85,39 +89,72 @@ internal fun QrDisplayCard( .clip(RoundedCornerShape(12.dp)) ) + // ── Action row: Share | Copy | Brightness | NFC ─────────────────────── Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically ) { - OutlinedButton( - onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) }, - modifier = Modifier.weight(1f) + // Share + IconButton( + onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) } ) { - Icon(Icons.Filled.Share, contentDescription = "Share", - modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Share") + Icon( + imageVector = Icons.Filled.Share, + contentDescription = "Share", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } - OutlinedButton( - onClick = { + + // Copy + IconButton( + onClick = { scope.launch { clipboard.setClipEntry( ClipEntry(ClipData.newPlainText(clipLabel, textToCopy)) ) } - }, - modifier = Modifier.weight(1f) + } ) { - Icon(Icons.Filled.ContentCopy, contentDescription = "Copy", - modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Copy") + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = "Copy", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Brightness + FilledTonalIconButton( + onClick = onToggleBrightness + ) { + Icon( + imageVector = Icons.Filled.WbSunny, + contentDescription = if (brightnessOn) "Reduce brightness" else "Boost brightness", + tint = if (brightnessOn) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // NFC — only shown when caller passes isNfcEmulating + if (isNfcEmulating != null && onToggleNfc != null) { + FilledTonalIconButton( + onClick = onToggleNfc, + enabled = nfcEnabled + ) { + Icon( + imageVector = if (isNfcEmulating) Icons.Filled.Nfc else Icons.Outlined.Nfc, + contentDescription = if (isNfcEmulating) "Stop NFC sharing" else "Share via NFC", + tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - BrightnessToggleButton(isOn = brightnessOn, onToggle = onToggleBrightness) } } } +// ── QR generation ──────────────────────────────────────────────────────────── + private const val QR_BITMAP_SIZE = 512 private fun generateQrBitmap(content: String): Bitmap { @@ -133,4 +170,3 @@ private fun generateQrBitmap(content: String): Bitmap { this[x, y] = if (bits[x, y]) Color.BLACK else Color.WHITE } } - diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt index c71fb91..5dd6538 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt @@ -21,10 +21,14 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Nfc +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.outlined.Nfc import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -59,24 +63,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitcointxoko.gudariwallet.ui.ReceiveState import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker +import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel import com.bitcointxoko.gudariwallet.util.fiatLabel import kotlinx.coroutines.delay @Composable fun ReceiveScreen( - vm: WalletViewModel, - onNavigateToPaymentDetail : (checkingId: String) -> Unit + vm : WalletViewModel, + nfcVm : NfcViewModel, // ← new + onNavigateToPaymentDetail: (checkingId: String) -> Unit ) { val receiveState by vm.receiveState.collectAsStateWithLifecycle() val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle() val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle() val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle() - val context = LocalContext.current - val window = (context as Activity).window + val context = LocalContext.current + val window = (context as Activity).window val originalBrightness = remember { window.attributes.screenBrightness } - var brightnessOn by remember { mutableStateOf(false) } - var showAddress by remember { mutableStateOf(false) } + var brightnessOn by remember { mutableStateOf(false) } + var showAddress by remember { mutableStateOf(false) } val onToggleBrightness = { val lp = window.attributes @@ -87,15 +93,25 @@ fun ReceiveScreen( brightnessOn = !brightnessOn } + // Stop NFC emulation and reset receive state when leaving the screen DisposableEffect(Unit) { onDispose { val lp = window.attributes lp.screenBrightness = originalBrightness window.attributes = lp + nfcVm.stopEmulating() // ← new: clean up HCE on exit vm.resetReceiveState() } } + // Also stop emulating if the invoice is reset mid-session + // (e.g. user taps "Cancel" or "New Invoice") + LaunchedEffect(receiveState) { + if (receiveState !is ReceiveState.InvoiceReady) { + nfcVm.stopEmulating() + } + } + Box(modifier = Modifier.fillMaxSize()) { if (receiveState !is ReceiveState.InvoiceReady) { Column( @@ -188,11 +204,15 @@ fun ReceiveScreen( fiatCurrency = fiatCurrency, brightnessOn = brightnessOn, onToggleBrightness = onToggleBrightness, + nfcVm = nfcVm, // ← new onReset = { vm.resetReceiveState() } ) } } } + +// ── LnurlWithdrawSheet (unchanged) ─────────────────────────────────────────── + @Composable private fun LnurlWithdrawSheet( state : ReceiveState.LnurlWithdrawReady, @@ -309,8 +329,13 @@ private fun LnurlWithdrawSheet( } } } + +// ── AmountUnit (unchanged) ──────────────────────────────────────────────────── + private enum class AmountUnit { SATS, FIAT } +// ── ReceiveIdleContent (unchanged) ─────────────────────────────────────────── + @Composable private fun ReceiveIdleContent( fiatRate : Double?, @@ -323,13 +348,11 @@ private fun ReceiveIdleContent( isLoading : Boolean, onCreateInvoice : (Long, String) -> Unit ) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current - // Wrap the toggle so that opening the address card always clears focus + keyboard val onToggleAddressWithDismiss = { if (!showAddress) { - // About to show address — dismiss keyboard and clear focus first keyboardController?.hide() focusManager.clearFocus() } @@ -347,7 +370,6 @@ private fun ReceiveIdleContent( ) { Spacer(Modifier.height(8.dp)) - // ── Header row ──────────────────────────────────────────────────────── Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -370,7 +392,6 @@ private fun ReceiveIdleContent( } } - // ── Lightning address card ──────────────────────────────────────────── AnimatedVisibility(visible = showAddress && lightningAddress != null) { Card( colors = CardDefaults.cardColors( @@ -417,7 +438,7 @@ private fun ReceiveIdleContent( fiatRate = fiatRate, fiatCurrency = fiatCurrency, addressShowing = showAddress, - onCollapseAddress = onToggleAddressWithDismiss, // ← consistent: same wrapper + onCollapseAddress = onToggleAddressWithDismiss, onNext = { text, unit -> confirmedAmountText = text confirmedUnit = unit @@ -439,6 +460,9 @@ private fun ReceiveIdleContent( } } } + +// ── AmountStepContent (unchanged) ──────────────────────────────────────────── + @Composable private fun AmountStepContent( fiatRate : Double?, @@ -485,8 +509,8 @@ private fun AmountStepContent( placeholder = { Text( if (activeUnit == AmountUnit.SATS || !showFiatToggle) "0" else "0.00", - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, fontFamily = FontFamily.Monospace ) }, @@ -497,7 +521,7 @@ private fun AmountStepContent( else -> null }, textStyle = LocalTextStyle.current.copy( - textAlign = TextAlign.End, + textAlign = TextAlign.End, fontFamily = FontFamily.Monospace ), keyboardOptions = KeyboardOptions( @@ -536,6 +560,9 @@ private fun AmountStepContent( } } } + +// ── MemoStepContent (unchanged) ────────────────────────────────────────────── + @Composable private fun MemoStepContent( confirmedSats : Long, @@ -578,6 +605,9 @@ private fun MemoStepContent( } } } + +// ── ReceiveInvoiceContent — NFC toggle added ────────────────────────────────── + @Composable private fun ReceiveInvoiceContent( state : ReceiveState.InvoiceReady, @@ -585,8 +615,11 @@ private fun ReceiveInvoiceContent( fiatCurrency : String?, brightnessOn : Boolean, onToggleBrightness : () -> Unit, + nfcVm : NfcViewModel, onReset : () -> Unit ) { + val isEmulating by nfcVm.isNfcEmulating.collectAsStateWithLifecycle() + var secondsLeft by remember { mutableLongStateOf( (state.expiresAt - System.currentTimeMillis() / 1000L).coerceAtLeast(0L) @@ -622,9 +655,7 @@ private fun ReceiveInvoiceContent( ) } - // ── QR — weight(1f) claims all remaining vertical space ─────────────── - // modifier targets the QrDisplayCard's outer Column (gives it the height) - // qrModifier targets the Image inside (fills that height, stays square) + // ── QR + action row (Share / Copy / Brightness / NFC) ───────────────── QrDisplayCard( content = state.bolt11.uppercase(), contentDescription = "Lightning invoice QR code", @@ -633,23 +664,30 @@ private fun ReceiveInvoiceContent( textToCopy = state.bolt11, brightnessOn = brightnessOn, onToggleBrightness = onToggleBrightness, + isNfcEmulating = isEmulating, + onToggleNfc = { + if (isEmulating) nfcVm.stopEmulating() + else nfcVm.startEmulating("lightning:${state.bolt11}") + }, + nfcEnabled = !isExpired, modifier = Modifier - .weight(1f) // outer Column gets all remaining height + .weight(1f) .fillMaxWidth(), qrModifier = Modifier - .fillMaxWidth() // Image fills the column width - .weight(1f) // Image also takes remaining height inside QrDisplayCard's Column + .fillMaxWidth() + .weight(1f) ) - // ── Status ──────────────────────────────────────────────────────────── + // ── Status row — restored to original simple format ─────────────────── Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) ) { if (!isExpired) { CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) Text( - text = "Waiting for payment…", + text = "Waiting…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -676,6 +714,9 @@ private fun ReceiveInvoiceContent( } } + +// ── ReceiveSuccessContent (unchanged) ──────────────────────────────────────── + @Composable private fun ReceiveSuccessContent( amountSats : Long, @@ -701,7 +742,6 @@ private fun ReceiveSuccessContent( Spacer(Modifier.height(16.dp)) Text("Payment Received!", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(8.dp)) - // AmountDisplay renders both the sats line and the fiat subtitle AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel) if (!memo.isNullOrBlank()) { Spacer(Modifier.height(4.dp)) @@ -716,4 +756,4 @@ private fun ReceiveSuccessContent( Text("Done") } } -} \ No newline at end of file +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 02a1baf..a1407d0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ Gudari Wallet + Gudari Wallet NFC payment sharing \ No newline at end of file diff --git a/app/src/main/res/xml/apduservice.xml b/app/src/main/res/xml/apduservice.xml new file mode 100644 index 0000000..77b76ee --- /dev/null +++ b/app/src/main/res/xml/apduservice.xml @@ -0,0 +1,17 @@ + + + + + + + + +