feat: add nfc step 1
This commit is contained in:
@@ -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
|
||||
@@ -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<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() }
|
||||
|
||||
Reference in New Issue
Block a user