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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <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.camera" android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -142,6 +142,15 @@ sealed interface ClipboardOfferState {
) : 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 * Shared interface for send states that represent a fully decoded invoice
* ready for user confirmation. Allows ConfirmCardContent to accept either * 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.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste 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.QrCode
import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.CircularProgressIndicator 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( Scaffold(
bottomBar = { bottomBar = {
if (showBottomBar) { 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 package com.bitcointxoko.gudariwallet.ui
import android.util.Log import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse 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.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel 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.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -33,7 +35,8 @@ class WalletViewModel(
val fiatVm : FiatViewModel, val fiatVm : FiatViewModel,
val receiveVm : ReceiveViewModel, val receiveVm : ReceiveViewModel,
val sendVm : SendViewModel, val sendVm : SendViewModel,
val clipboardVm : ClipboardViewModel, val clipboardVm : ClipboardViewModel,
val nfcVm : NfcViewModel
) : ViewModel() { ) : ViewModel() {
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
val balanceHidden: StateFlow<Boolean> get() = fiatVm.balanceHidden val balanceHidden: StateFlow<Boolean> get() = fiatVm.balanceHidden
@@ -62,9 +65,7 @@ class WalletViewModel(
fun requestPayment(activity: FragmentActivity, subtitle: String, pay: () -> Unit) = fun requestPayment(activity: FragmentActivity, subtitle: String, pay: () -> Unit) =
sendVm.requestPayment(activity, subtitle, pay) sendVm.requestPayment(activity, subtitle, pay)
fun resetSendState() = sendVm.resetSendState() 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 val lightningAddress: StateFlow<String?> = lnAddressStore.lightningAddressFlow
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -72,6 +73,21 @@ class WalletViewModel(
initialValue = null 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 { init {
observePaymentEvents() observePaymentEvents()
fetchLightningAddress() fetchLightningAddress()
@@ -17,6 +17,7 @@ import com.bitcointxoko.gudariwallet.service.NodeAliasService
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel 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.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel 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( val clipboardVm = ClipboardViewModel(
ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 }, ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 },
ownAddress = { lnAddressStore.currentAddress } ownAddress = { lnAddressStore.currentAddress }
) )
val nfcVm = NfcViewModel(
ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 },
ownAddress = { lnAddressStore.currentAddress }
)
balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() } balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() }
@@ -65,6 +68,7 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
receiveVm = receiveVm, receiveVm = receiveVm,
sendVm = sendVm, sendVm = sendVm,
clipboardVm = clipboardVm, clipboardVm = clipboardVm,
nfcVm = nfcVm,
lnAddressStore = lnAddressStore, lnAddressStore = lnAddressStore,
) as T ) 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.Icons
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -34,7 +33,8 @@ import kotlinx.coroutines.launch
import android.graphics.Color import android.graphics.Color
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio 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.Alignment
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
@@ -54,6 +54,7 @@ import androidx.core.graphics.set
*/ */
@Composable @Composable
internal fun QrDisplayCard( internal fun QrDisplayCard(
modifier : Modifier = Modifier,
content : String, content : String,
contentDescription : String, contentDescription : String,
clipLabel : String, clipLabel : String,
@@ -61,7 +62,7 @@ internal fun QrDisplayCard(
textToCopy : String, textToCopy : String,
brightnessOn : Boolean, brightnessOn : Boolean,
onToggleBrightness : () -> Unit, onToggleBrightness : () -> Unit,
modifier : Modifier = Modifier, onNfcBroadcast : (() -> Unit)? = null,
qrModifier : Modifier = Modifier.size(260.dp) qrModifier : Modifier = Modifier.size(260.dp)
) { ) {
val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() } val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() }