feat: add nfc step 1

This commit is contained in:
2026-06-05 01:47:39 +02:00
parent 8e53a927a0
commit cc50ff08df
7 changed files with 340 additions and 9 deletions
+3
View File
@@ -7,8 +7,11 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<application
android:allowBackup="true"
@@ -142,6 +142,15 @@ sealed interface ClipboardOfferState {
) : ClipboardOfferState
}
sealed interface NfcOfferState {
data object None : NfcOfferState
data class Detected(
val raw : String,
val inputType: SendInputType
) : NfcOfferState
}
/**
* Shared interface for send states that represent a fully decoded invoice
* ready for user confirmation. Allows ConfirmCardContent to accept either
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.CircularProgressIndicator
@@ -160,6 +161,36 @@ fun WalletScreen(
}
}
// ── NFC reader mode — register on composition, unregister on disposal ────────
val nfcAdapter = remember {
android.nfc.NfcAdapter.getDefaultAdapter(context)
}
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()
}
}
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")
}
}
}
}
}
}
}
}
@@ -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
@@ -34,6 +36,7 @@ class WalletViewModel(
val receiveVm : ReceiveViewModel,
val sendVm : SendViewModel,
val clipboardVm : ClipboardViewModel,
val nfcVm : NfcViewModel
) : ViewModel() {
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
val balanceHidden: StateFlow<Boolean> 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<ClipboardOfferState> get() = clipboardVm.clipboardOfferState
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
val lightningAddress: StateFlow<String?> = lnAddressStore.lightningAddressFlow
.stateIn(
scope = viewModelScope,
@@ -72,6 +73,21 @@ class WalletViewModel(
initialValue = null
)
val clipboardOfferState: StateFlow<ClipboardOfferState> get() = clipboardVm.clipboardOfferState
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
val nfcOfferState: StateFlow<NfcOfferState> = 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<NfcViewModel.NfcBroadcastState> get() = nfcVm.broadcastState
fun startNfcBroadcast(payload: String) = nfcVm.startBroadcast(payload)
fun cancelNfcBroadcast() = nfcVm.cancelBroadcast()
fun dismissNfcBroadcastResult() = nfcVm.dismissBroadcastResult()
init {
observePaymentEvents()
fetchLightningAddress()
@@ -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<String?>(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
}
@@ -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>(NfcOfferState.None)
val nfcOfferState: StateFlow<NfcOfferState> = _nfcOfferState
private val _broadcastState = MutableStateFlow<NfcBroadcastState>(NfcBroadcastState.Idle)
val broadcastState: StateFlow<NfcBroadcastState> = _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)
}
}
@@ -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() }