diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8489cde..a22b927 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,8 +7,11 @@
+
+
+
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()
+ }
+ }
+
Scaffold(
bottomBar = {
if (showBottomBar) {
@@ -414,6 +445,70 @@ fun WalletScreen(
}
}
}
+ // ── NFC banner — floats below clipboard banner, slides in from top ────────────
+ AnimatedVisibility(
+ visible = nfcOffer is NfcOfferState.Detected,
+ enter = slideInVertically(initialOffsetY = { -it }),
+ exit = slideOutVertically(targetOffsetY = { -it }),
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .zIndex(1f)
+ .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) {
+ is SendInputType.Bolt11 -> "Invoice"
+ is SendInputType.Lnurl -> "LNURL"
+ is SendInputType.LightningAddress -> "Lightning Address"
+ else -> "Payment"
+ }
+ val subLabel = when (offer.inputType) {
+ is SendInputType.Lnurl -> "Tap to open"
+ else -> "Tap to pay"
+ }
+ Surface(
+ color = MaterialTheme.colorScheme.secondaryContainer, // distinct from clipboard's primaryContainer
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ vm.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
+ }
+ }
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Filled.Nfc, contentDescription = null)
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(
+ "$label detected via NFC",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ subLabel,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
+ )
+ }
+ IconButton(onClick = { vm.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 9013018..c04b937 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.ui
import android.util.Log
+import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
@@ -16,6 +17,7 @@ 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
@@ -33,7 +35,8 @@ class WalletViewModel(
val fiatVm : FiatViewModel,
val receiveVm : ReceiveViewModel,
val sendVm : SendViewModel,
- val clipboardVm : ClipboardViewModel,
+ val clipboardVm : ClipboardViewModel,
+ val nfcVm : NfcViewModel
) : ViewModel() {
val balanceState: StateFlow get() = balanceVm.balanceState
val balanceHidden: StateFlow get() = fiatVm.balanceHidden
@@ -62,9 +65,7 @@ class WalletViewModel(
fun requestPayment(activity: FragmentActivity, subtitle: String, pay: () -> Unit) =
sendVm.requestPayment(activity, subtitle, pay)
fun resetSendState() = sendVm.resetSendState()
- val clipboardOfferState: StateFlow get() = clipboardVm.clipboardOfferState
- fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
- fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
+
val lightningAddress: StateFlow = lnAddressStore.lightningAddressFlow
.stateIn(
scope = viewModelScope,
@@ -72,6 +73,21 @@ class WalletViewModel(
initialValue = null
)
+ val clipboardOfferState: StateFlow get() = clipboardVm.clipboardOfferState
+ 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 65a3bdb..61f0e0a 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt
@@ -17,6 +17,7 @@ import com.bitcointxoko.gudariwallet.service.NodeAliasService
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
@@ -45,12 +46,14 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
}
)
-// val lightningAddress = MutableStateFlow(balancePrefs.lightningAddress)
-
val clipboardVm = ClipboardViewModel(
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() }
@@ -65,6 +68,7 @@ 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/nfc/NfcViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt
new file mode 100644
index 0000000..3790d9c
--- /dev/null
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt
@@ -0,0 +1,203 @@
+// ui/nfc/NfcViewModel.kt
+package com.bitcointxoko.gudariwallet.ui.nfc
+
+import android.nfc.NdefMessage
+import android.nfc.NdefRecord
+import android.nfc.Tag
+import android.nfc.tech.Ndef
+import android.nfc.tech.NdefFormatable
+import android.util.Log
+import androidx.lifecycle.ViewModel
+import com.bitcointxoko.gudariwallet.ui.NfcOfferState
+import com.bitcointxoko.gudariwallet.util.SendInputDetector
+import com.bitcointxoko.gudariwallet.util.SendInputType
+import kotlinx.coroutines.flow.MutableStateFlow
+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() {
+
+ // ── Broadcast state ───────────────────────────────────────────────────────────
+
+ 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 _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
+ }
+
+ /**
+ * 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).
+ */
+ 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
+ }
+ }
+
+ // ── Called from WalletScreen's NFC reader-mode callback ──────────────────
+ 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)
+ }
+
+ 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")
+ return
+ }
+
+ val type = SendInputDetector.detect(trimmed)
+ if (type == SendInputType.Unknown) {
+ Log.d(TAG, "Unknown input type, ignoring")
+ return
+ }
+
+ _nfcOfferState.value = NfcOfferState.Detected(raw = trimmed, inputType = type)
+ }
+
+ fun dismissNfcOffer() {
+ _nfcOfferState.value = NfcOfferState.None
+ }
+
+ // ── NDEF decoding ─────────────────────────────────────────────────────────
+ private fun decodeNdefTag(tag: Tag): String? {
+ val ndef = Ndef.get(tag) ?: return null
+ return try {
+ 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() }
+ }
+ }
+
+ 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
+ }
+ // Absolute URI (some tags encode this way)
+ NdefRecord.TNF_ABSOLUTE_URI ->
+ String(record.payload, Charsets.UTF_8)
+
+ else -> 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
+ */
+ 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)
+ }
+}
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 69dc888..ab8ed22 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
@@ -13,7 +13,6 @@ 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.Share
-import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
@@ -34,7 +33,8 @@ import kotlinx.coroutines.launch
import android.graphics.Color
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.height
+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
@@ -54,6 +54,7 @@ import androidx.core.graphics.set
*/
@Composable
internal fun QrDisplayCard(
+ modifier : Modifier = Modifier,
content : String,
contentDescription : String,
clipLabel : String,
@@ -61,7 +62,7 @@ internal fun QrDisplayCard(
textToCopy : String,
brightnessOn : Boolean,
onToggleBrightness : () -> Unit,
- modifier : Modifier = Modifier,
+ onNfcBroadcast : (() -> Unit)? = null,
qrModifier : Modifier = Modifier.size(260.dp)
) {
val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() }