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.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
|
||||||
@@ -34,6 +36,7 @@ class WalletViewModel(
|
|||||||
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() }
|
||||||
|
|||||||
Reference in New Issue
Block a user