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() }