feat: add nfc step 2 - receive with invoice

This commit is contained in:
2026-06-05 15:43:10 +02:00
parent cc50ff08df
commit 82c048f6c0
13 changed files with 588 additions and 316 deletions
+20 -3
View File
@@ -7,11 +7,12 @@
<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.FOREGROUND_SERVICE_SPECIAL_USE" />
<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" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<application
android:allowBackup="true"
@@ -55,8 +56,24 @@
<service
android:name=".service.WalletNotificationService"
android:foregroundServiceType="dataSync"
android:exported="false" />
android:foregroundServiceType="specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains a persistent Lightning Network connection to receive payment notifications" />
</service>
<service
android:name=".ui.nfc.NdefHceService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/apduservice" />
</service>
</application>
@@ -1,8 +1,11 @@
package com.bitcointxoko.gudariwallet
import android.Manifest
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
@@ -25,6 +28,7 @@ import com.bitcointxoko.gudariwallet.ui.OnboardingScreen
import com.bitcointxoko.gudariwallet.ui.WalletScreen
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.theme.GudariWalletTheme
import com.bitcointxoko.gudariwallet.util.NotificationHelper
import com.bitcointxoko.gudariwallet.util.IntentUtils
@@ -40,13 +44,30 @@ class MainActivity : AppCompatActivity() {
WalletViewModelFactory(application)
}
// ── NFC ──────────────────────────────────────────────────────────────────
val nfcVm: NfcViewModel by viewModels()
private var nfcAdapter: NfcAdapter? = null
/** Pending intent used by foreground dispatch — routes tag intents back here. */
private val nfcPendingIntent: PendingIntent by lazy {
PendingIntent.getActivity(
this, 0,
Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_MUTABLE
)
}
// ── Existing state ───────────────────────────────────────────────────────
// Holds a paymentHash that arrived via notification tap.
// WalletScreen observes this and navigates to the detail screen.
// MutableState so Compose recomposes when it changes.
private val pendingPaymentHash = mutableStateOf<String?>(null)
// Holds a payment URI that arrived via deep link / intent (BTCPay, external apps).
private val pendingPaymentUri = mutableStateOf<String?>(null)
private val pendingPaymentUri = mutableStateOf<String?>(null)
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* informational */ }
@@ -73,10 +94,14 @@ class MainActivity : AppCompatActivity() {
NotificationHelper.createChannels(applicationContext)
// Read hash from the intent that launched the activity (cold start from notification)
pendingPaymentHash.value = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
pendingPaymentHash.value =
intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
// Read payment URI from the intent that launched the activity (cold start from deep link)
pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent)
// Initialise NFC adapter (null on devices without NFC — handled gracefully)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
enableEdgeToEdge()
setContent {
@@ -94,6 +119,7 @@ class MainActivity : AppCompatActivity() {
}
WalletScreen(
vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash,
pendingPaymentUri = pendingPaymentUri
)
@@ -105,26 +131,55 @@ class MainActivity : AppCompatActivity() {
}
}
}
}
// Called when the activity is already running and a notification is tapped
// ── NFC foreground dispatch ───────────────────────────────────────────────
override fun onResume() {
super.onResume()
// Give this activity first pick of all NFC tag intents while foregrounded.
// Passing null for techLists and intentFilters means we intercept every tag type.
nfcAdapter?.enableForegroundDispatch(
this,
nfcPendingIntent,
null, // intercept all intent filters
null // intercept all tech lists
)
}
override fun onPause() {
super.onPause()
nfcAdapter?.disableForegroundDispatch(this)
}
// ── Intent routing ────────────────────────────────────────────────────────
// Called when the activity is already running and a notification is tapped,
// a deep link arrives, or an NFC tag is discovered while foregrounded.
// (FLAG_ACTIVITY_SINGLE_TOP prevents a new instance being created)
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
// Notification tap → navigate to payment detail
val hash = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
if (hash != null) {
pendingPaymentHash.value = hash
}
// Deep link / external app URI
val uri = IntentUtils.extractPaymentUri(intent)
if (uri != null) {
pendingPaymentUri.value = uri
}
// NFC tag discovered → hand off to NfcViewModel for reading
val tag = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
if (tag != null) {
nfcVm.onTagDiscovered(tag)
}
}
// ── Permission / battery helpers (unchanged) ──────────────────────────────
private fun requestNotificationPermissionIfNeeded() {
@@ -154,9 +209,10 @@ class MainActivity : AppCompatActivity() {
}
private fun openBatterySettings() {
val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
val appDetailsIntent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
if (tryStartActivity(appDetailsIntent)) return
tryStartActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
}
@@ -104,17 +104,12 @@ class WalletNotificationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Step 1: Call startForeground immediately — Android requires this within
// 5 seconds of startForegroundService() or the app is ANR'd.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NotificationConstants.NOTIF_ID_SERVICE,
NotificationHelper.buildServiceNotification(this),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34
startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(
NotificationConstants.NOTIF_ID_SERVICE,
NotificationHelper.buildServiceNotification(this)
)
startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this))
}
// Step 2: Open the WebSocket. If already open (e.g. service restarted
@@ -58,6 +58,7 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.util.SendInputType
@@ -67,7 +68,7 @@ import kotlinx.coroutines.delay
sealed class TabItem(val route: String, val label: String, val icon: ImageVector) {
data object Receive : TabItem("receive", "Receive", Icons.Filled.QrCode)
data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send)
data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send)
}
private const val ROUTE_HOME = "home"
@@ -78,6 +79,7 @@ private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
@Composable
fun WalletScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel, // ← new
pendingPaymentHash: MutableState<String?>,
pendingPaymentUri : MutableState<String?>
) {
@@ -85,10 +87,10 @@ fun WalletScreen(
val context = LocalContext.current
val historyFactory = remember {
HistoryViewModelFactory(
repo = vm.repo,
aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache,
fiatCurrency = vm.selectedCurrency,
repo = vm.repo,
aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache,
fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit
)
}
@@ -143,7 +145,7 @@ fun WalletScreen(
LaunchedEffect(intentUri) {
if (intentUri != null) {
Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri")
pendingPaymentUri.value = null // consume immediately
pendingPaymentUri.value = null
vm.scan(intentUri)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
@@ -151,9 +153,9 @@ fun WalletScreen(
}
}
// ── Clipboard offer ───────────────────────────────────────────────────────
val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle()
// Auto-dismiss banner after 8 seconds
LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000)
@@ -161,33 +163,13 @@ fun WalletScreen(
}
}
// ── NFC reader mode — register on composition, unregister on disposal ────────
val nfcAdapter = remember {
android.nfc.NfcAdapter.getDefaultAdapter(context)
}
// ── NFC offer (tag read while app is foregrounded) ────────────────────────
val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()
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()
nfcVm.dismissNfcOffer()
}
}
@@ -304,7 +286,8 @@ fun WalletScreen(
}
composable(TabItem.Receive.route) {
ReceiveScreen(
vm,
vm = vm,
nfcVm = nfcVm, // ← new
onNavigateToPaymentDetail = { checkingId ->
navController.navigate("payment_detail/$checkingId")
}
@@ -339,9 +322,8 @@ fun WalletScreen(
// History — full screen, no bottom chrome, close → home
composable(
ROUTE_HISTORY,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
HistoryScreen(
vm = historyVm,
@@ -371,8 +353,8 @@ fun WalletScreen(
if (payment != null) {
PaymentDetailScreen(
payment = payment,
vm = historyVm,
onBack = { navController.popBackStack() }
vm = historyVm,
onBack = { navController.popBackStack() }
)
} else {
LaunchedEffect(checkingId) { historyVm.refresh() }
@@ -393,13 +375,13 @@ fun WalletScreen(
exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(1f)
.zIndex(2f) // above NFC banner
.fillMaxWidth()
.statusBarsPadding()
) {
if (clipboardOffer is ClipboardOfferState.Detected) {
val offer = clipboardOffer as ClipboardOfferState.Detected
val label = when (offer.inputType) {
val offer = clipboardOffer as ClipboardOfferState.Detected
val label = when (offer.inputType) {
is SendInputType.Bolt11 -> "Invoice"
is SendInputType.Lnurl -> "LNURL"
is SendInputType.LightningAddress -> "Lightning Address"
@@ -445,22 +427,23 @@ fun WalletScreen(
}
}
}
// ── NFC banner — floats below clipboard banner, slides in from top ────────────
// ── NFC banner — floats over content, slides in from top ──────────
// Shown when an NFC tag is tapped while the app is foregrounded.
// Mirrors the clipboard banner pattern exactly.
AnimatedVisibility(
visible = nfcOffer is NfcOfferState.Detected,
enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(1f)
.zIndex(1f) // below clipboard banner if both show
.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) {
val offer = nfcOffer as NfcOfferState.Detected
val label = when (offer.inputType) {
is SendInputType.Bolt11 -> "Invoice"
is SendInputType.Lnurl -> "LNURL"
is SendInputType.LightningAddress -> "Lightning Address"
@@ -471,15 +454,12 @@ fun WalletScreen(
else -> "Tap to pay"
}
Surface(
color = MaterialTheme.colorScheme.secondaryContainer, // distinct from clipboard's primaryContainer
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier
.fillMaxWidth()
.clickable {
vm.dismissNfcOffer()
nfcVm.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
}
@@ -489,7 +469,7 @@ fun WalletScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.Nfc, contentDescription = null)
Icon(Icons.Default.Nfc, contentDescription = null)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
@@ -502,7 +482,7 @@ fun WalletScreen(
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
)
}
IconButton(onClick = { vm.dismissNfcOffer() }) {
IconButton(onClick = { nfcVm.dismissNfcOffer() }) {
Icon(Icons.Default.Close, contentDescription = "Dismiss")
}
}
@@ -17,7 +17,6 @@ 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
@@ -35,8 +34,7 @@ class WalletViewModel(
val fiatVm : FiatViewModel,
val receiveVm : ReceiveViewModel,
val sendVm : SendViewModel,
val clipboardVm : ClipboardViewModel,
val nfcVm : NfcViewModel
val clipboardVm : ClipboardViewModel
) : ViewModel() {
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
val balanceHidden: StateFlow<Boolean> get() = fiatVm.balanceHidden
@@ -77,17 +75,6 @@ class WalletViewModel(
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()
@@ -50,10 +50,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
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() }
@@ -68,7 +64,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
receiveVm = receiveVm,
sendVm = sendVm,
clipboardVm = clipboardVm,
nfcVm = nfcVm,
lnAddressStore = lnAddressStore,
) as T
}
@@ -3,12 +3,10 @@ package com.bitcointxoko.gudariwallet.ui.clipboard
import android.util.Log
import androidx.lifecycle.ViewModel
import com.bitcointxoko.gudariwallet.ui.ClipboardOfferState
import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.StateFlow as SF
private const val TAG = "ClipboardViewModel"
@@ -0,0 +1,152 @@
package com.bitcointxoko.gudariwallet.ui.nfc
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import android.util.Log
/**
* HCE service that emulates an NFC Forum Type 4 Tag containing an NDEF
* message. The NDEF message holds a URI record (lightning: or bitcoin: URI)
* set by NfcViewModel via the companion-object singleton slot.
*
* Protocol reference: NFC Forum Type 4 Tag Operation Specification.
* The reader performs:
* 1. SELECT NDEF Application (AID D2760000850101)
* 2. SELECT Capability Container file (file ID 0xE103)
* 3. READ BINARY of CC file
* 4. SELECT NDEF file (file ID from CC)
* 5. READ BINARY of NDEF file (length prefix + NDEF message bytes)
*/
class NdefHceService : HostApduService() {
companion object {
private const val TAG = "NdefHceService"
// ── APDU status words ────────────────────────────────────────────────
private val SW_OK = byteArrayOf(0x90.toByte(), 0x00)
private val SW_FILE_NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte())
private val SW_UNKNOWN = byteArrayOf(0x6F.toByte(), 0x00)
// ── File IDs ─────────────────────────────────────────────────────────
private val CC_FILE_ID = byteArrayOf(0xE1.toByte(), 0x03.toByte())
private val NDEF_FILE_ID = byteArrayOf(0xE1.toByte(), 0x04.toByte())
// ── NDEF Application AID ─────────────────────────────────────────────
private val NDEF_AID = byteArrayOf(
0xD2.toByte(), 0x76.toByte(), 0x00.toByte(), 0x00.toByte(),
0x85.toByte(), 0x01.toByte(), 0x01.toByte()
)
// ── Capability Container (CC) file ───────────────────────────────────
// Standard 15-byte CC for a Type 4 Tag:
// Bytes 0-1 : CC length (0x000F = 15)
// Byte 2 : Mapping version (0x20 = v2.0)
// Bytes 3-4 : Max R-APDU data size (0x007F = 127)
// Bytes 5-6 : Max C-APDU data size (0x007F = 127)
// Bytes 7-8 : NDEF File Control TLV tag+length (0x04, 0x06)
// Bytes 9-10: NDEF file ID (0xE104)
// Bytes 11-12: Max NDEF file size (0x0400 = 1024)
// Byte 13 : Read access (0x00 = open)
// Byte 14 : Write access (0xFF = no write)
private val CC_FILE = byteArrayOf(
0x00, 0x0F,
0x20,
0x00, 0x7F,
0x00, 0x7F,
0x04, 0x06,
0xE1.toByte(), 0x04,
0x04, 0x00,
0x00,
0xFF.toByte()
)
// ── Shared NDEF payload slot ─────────────────────────────────────────
// Written by NfcViewModel, read by processCommandApdu().
// Volatile is sufficient — single writer, single reader on binder thread.
@Volatile
var ndefFileBytes: ByteArray? = null
}
// Which file is currently selected (null = NDEF app selected, no file yet)
private var selectedFileId: ByteArray? = null
override fun processCommandApdu(apdu: ByteArray, extras: Bundle?): ByteArray {
Log.d(TAG, "APDU IN: ${apdu.toHex()}")
if (apdu.size < 4) return SW_UNKNOWN
val ins = apdu[1]
return when {
// ── SELECT (INS = 0xA4) ──────────────────────────────────────────
ins == 0xA4.toByte() -> handleSelect(apdu)
// ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
ins == 0xB0.toByte() -> handleReadBinary(apdu)
else -> SW_UNKNOWN
}.also { Log.d(TAG, "APDU OUT: ${it.toHex()}") }
}
private fun handleSelect(apdu: ByteArray): ByteArray {
if (apdu.size < 5) return SW_UNKNOWN
val dataLen = apdu[4].toInt() and 0xFF
if (apdu.size < 5 + dataLen) return SW_UNKNOWN
val data = apdu.copyOfRange(5, 5 + dataLen)
return when {
data.contentEquals(NDEF_AID) -> {
selectedFileId = null // app selected, no file yet
Log.d(TAG, "SELECT NDEF Application OK")
SW_OK
}
data.contentEquals(CC_FILE_ID) -> {
selectedFileId = CC_FILE_ID
Log.d(TAG, "SELECT CC file OK")
SW_OK
}
data.contentEquals(NDEF_FILE_ID) -> {
selectedFileId = NDEF_FILE_ID
Log.d(TAG, "SELECT NDEF file OK")
SW_OK
}
else -> {
Log.w(TAG, "SELECT unknown file: ${data.toHex()}")
SW_FILE_NOT_FOUND
}
}
}
private fun handleReadBinary(apdu: ByteArray): ByteArray {
if (apdu.size < 5) return SW_UNKNOWN
val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF)
val length = apdu[4].toInt() and 0xFF
val fileData: ByteArray = when {
selectedFileId != null && selectedFileId!!.contentEquals(CC_FILE_ID) ->
CC_FILE
selectedFileId != null && selectedFileId!!.contentEquals(NDEF_FILE_ID) -> {
ndefFileBytes ?: run {
Log.w(TAG, "READ BINARY: no NDEF data loaded")
return SW_FILE_NOT_FOUND
}
}
else -> return SW_FILE_NOT_FOUND
}
if (offset >= fileData.size) return SW_FILE_NOT_FOUND
val end = minOf(offset + length, fileData.size)
val chunk = fileData.copyOfRange(offset, end)
return chunk + SW_OK
}
override fun onDeactivated(reason: Int) {
Log.d(TAG, "HCE deactivated, reason=$reason")
selectedFileId = null
}
// ── Helper ───────────────────────────────────────────────────────────────
private fun ByteArray.toHex(): String =
joinToString("") { "%02X".format(it) }
}
@@ -1,13 +1,17 @@
// ui/nfc/NfcViewModel.kt
package com.bitcointxoko.gudariwallet.ui.nfc
import android.content.ComponentName
import android.content.Context
import android.nfc.NfcAdapter
import android.nfc.NdefMessage
import android.nfc.NdefRecord
import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.Ndef
import android.nfc.tech.NdefFormatable
import android.util.Log
import androidx.lifecycle.ViewModel
import com.bitcointxoko.gudariwallet.ui.nfc.NdefHceService
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
@@ -16,129 +20,60 @@ 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() {
class NfcViewModel : ViewModel() {
// ── Broadcast state ───────────────────────────────────────────────────────────
// ── NFC receive (HCE emulation) ──────────────────────────────────────────
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 _isNfcEmulating = MutableStateFlow(false)
val isNfcEmulating: StateFlow<Boolean> = _isNfcEmulating
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
/**
* Start emulating an NFC tag containing [uri] (e.g. "lightning:lnbc...").
* Builds the NDEF file bytes and loads them into NdefHceService.
*/
fun startEmulating(uri: String) {
Log.d(TAG, "startEmulating: $uri")
NdefHceService.ndefFileBytes = buildNdefFileBytes(uri)
_isNfcEmulating.value = true
}
/**
* 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).
* Stop emulating — clears the NDEF payload from the HCE service.
*/
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
}
fun stopEmulating() {
Log.d(TAG, "stopEmulating")
NdefHceService.ndefFileBytes = null
_isNfcEmulating.value = false
}
// ── Called from WalletScreen's NFC reader-mode callback ──────────────────
override fun onCleared() {
super.onCleared()
stopEmulating()
}
// ── NFC send (tag reading) ───────────────────────────────────────────────
private val _nfcOfferState = MutableStateFlow<NfcOfferState>(NfcOfferState.None)
val nfcOfferState: StateFlow<NfcOfferState> = _nfcOfferState
/**
* Called from MainActivity.onNewIntent when an NFC tag is discovered.
* Reads the NDEF message from the tag and checks if it contains a
* recognisable payment URI.
*/
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)
}
Log.d(TAG, "onTagDiscovered: ${tag.id?.toHex()}")
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")
val text = readNdefText(tag) ?: run {
Log.w(TAG, "Could not read NDEF from tag")
return
}
Log.d(TAG, "Tag NDEF text: $text")
val trimmed = text.trim()
val type = SendInputDetector.detect(trimmed)
if (type == SendInputType.Unknown) {
Log.d(TAG, "Unknown input type, ignoring")
Log.d(TAG, "Tag content not a recognised payment URI")
return
}
@@ -149,55 +84,118 @@ class NfcViewModel(
_nfcOfferState.value = NfcOfferState.None
}
// ── NDEF decoding ─────────────────────────────────────────────────────────
private fun decodeNdefTag(tag: Tag): String? {
val ndef = Ndef.get(tag) ?: return null
return try {
// ── NDEF reading ─────────────────────────────────────────────────────────
private fun readNdefText(tag: Tag): String? {
// Try standard NDEF first (covers Type 15 tags, NTAG21x, etc.)
Ndef.get(tag)?.use { ndef ->
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() }
val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return null
return extractUri(msg)
}
}
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
// Try NdefFormatable (blank tags — nothing to read)
// Try IsoDep last (Type 4 / ISO-DEP tags like Bolt Cards)
IsoDep.get(tag)?.use { isoDep ->
isoDep.connect()
isoDep.timeout = 3000
val msg = readNdefFromIsoDep(isoDep) ?: return null
return extractUri(msg)
}
// Absolute URI (some tags encode this way)
NdefRecord.TNF_ABSOLUTE_URI ->
String(record.payload, Charsets.UTF_8)
else -> null
return 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
* Reads an NDEF message from an ISO-DEP (Type 4) tag by replaying the
* same APDU sequence our HCE service responds to.
*/
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)
private fun readNdefFromIsoDep(isoDep: IsoDep): NdefMessage? {
fun ByteArray.sw(): Int =
((this[size - 2].toInt() and 0xFF) shl 8) or (this[size - 1].toInt() and 0xFF)
fun ByteArray.data(): ByteArray = copyOfRange(0, size - 2)
fun ByteArray.isOk() = sw() == 0x9000
// 1. SELECT NDEF Application
val selectApp = byteArrayOf(
0x00, 0xA4.toByte(), 0x04, 0x00, 0x07,
0xD2.toByte(), 0x76, 0x00, 0x00, 0x85.toByte(), 0x01, 0x01,
0x00
)
if (!isoDep.transceive(selectApp).isOk()) return null
// 2. SELECT CC file
val selectCc = byteArrayOf(0x00, 0xA4.toByte(), 0x00, 0x0C, 0x02, 0xE1.toByte(), 0x03)
if (!isoDep.transceive(selectCc).isOk()) return null
// 3. READ CC (15 bytes)
val readCc = byteArrayOf(0x00, 0xB0.toByte(), 0x00, 0x00, 0x0F)
val ccResp = isoDep.transceive(readCc)
if (!ccResp.isOk() || ccResp.size < 17) return null
val cc = ccResp.data()
// Parse NDEF file ID and max size from CC
val ndefFileId = cc.copyOfRange(9, 11)
val maxNdefSize = ((cc[11].toInt() and 0xFF) shl 8) or (cc[12].toInt() and 0xFF)
// 4. SELECT NDEF file
val selectNdef = byteArrayOf(0x00, 0xA4.toByte(), 0x00, 0x0C, 0x02) + ndefFileId
if (!isoDep.transceive(selectNdef).isOk()) return null
// 5. READ NDEF length (first 2 bytes)
val readLen = byteArrayOf(0x00, 0xB0.toByte(), 0x00, 0x00, 0x02)
val lenResp = isoDep.transceive(readLen)
if (!lenResp.isOk()) return null
val ndefLen = ((lenResp[0].toInt() and 0xFF) shl 8) or (lenResp[1].toInt() and 0xFF)
if (ndefLen == 0 || ndefLen > maxNdefSize) return null
// 6. READ NDEF data
val readData = byteArrayOf(
0x00, 0xB0.toByte(),
0x00, 0x02, // offset 2 (skip the 2-byte length)
ndefLen.coerceAtMost(0xFF).toByte()
)
val dataResp = isoDep.transceive(readData)
if (!dataResp.isOk()) return null
return runCatching { NdefMessage(dataResp.data()) }.getOrNull()
}
// ── NDEF helpers ─────────────────────────────────────────────────────────
private fun extractUri(msg: NdefMessage): String? {
for (record in msg.records) {
val uri = record.toUri()
if (uri != null) return uri.toString()
// Fallback: plain text record
if (record.tnf == NdefRecord.TNF_WELL_KNOWN &&
record.type.contentEquals(NdefRecord.RTD_TEXT)
) {
val payload = record.payload
val langLen = payload[0].toInt() and 0x3F
return String(payload, 1 + langLen, payload.size - 1 - langLen, Charsets.UTF_8)
}
}
return null
}
// ── NDEF file builder (for HCE emulation) ────────────────────────────────
/**
* Builds the raw bytes of the NDEF file as served by the HCE service.
* Layout: [2-byte NDEF length (big-endian)] [NDEF message bytes]
*/
private fun buildNdefFileBytes(uri: String): ByteArray {
val record = NdefRecord.createUri(uri)
val ndefMessage = NdefMessage(record)
val msgBytes = ndefMessage.toByteArray()
val len = msgBytes.size
return byteArrayOf(
((len shr 8) and 0xFF).toByte(),
(len and 0xFF).toByte()
) + msgBytes
}
private fun ByteArray.toHex(): String = joinToString("") { "%02X".format(it) }
}
@@ -2,55 +2,57 @@ package com.bitcointxoko.gudariwallet.ui.receive
import android.content.ClipData
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.Nfc
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material.icons.outlined.Nfc
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import com.bitcointxoko.gudariwallet.util.IntentUtils
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.launch
import android.graphics.Color
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
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
import androidx.core.graphics.set
/**
* QR image followed by Copy / Share / Brightness action row.
* QR image followed by Share / Copy / Brightness / NFC icon-button row.
*
* @param content What to encode into QR
* @param contentDescription Accessibility label for the image.
* @param clipLabel Label used when writing to the clipboard (e.g. "Lightning Address").
* @param shareTitle Chooser title for the share intent.
* @param textToCopy The raw string placed on the clipboard and shared.
* @param brightnessOn Current brightness-boost state.
* @param onToggleBrightness Called when the brightness button is tapped.
* @param content What to encode into the QR code.
* @param contentDescription Accessibility label for the QR image.
* @param clipLabel Label used when writing to the clipboard.
* @param shareTitle Chooser title for the share intent.
* @param textToCopy The raw string placed on the clipboard and shared.
* @param brightnessOn Current brightness-boost state.
* @param onToggleBrightness Called when the brightness button is tapped.
* @param isNfcEmulating Current NFC emulation state (null = hide NFC button).
* @param onToggleNfc Called when the NFC button is tapped.
* @param nfcEnabled Whether the NFC button should be interactive (e.g. false when invoice expired).
*/
@Composable
internal fun QrDisplayCard(
@@ -62,11 +64,12 @@ internal fun QrDisplayCard(
textToCopy : String,
brightnessOn : Boolean,
onToggleBrightness : () -> Unit,
onNfcBroadcast : (() -> Unit)? = null,
isNfcEmulating : Boolean? = null, // null → no NFC button shown
onToggleNfc : (() -> Unit)? = null,
nfcEnabled : Boolean = true,
qrModifier : Modifier = Modifier.size(260.dp)
) {
val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() }
val bitmap = remember(content) { generateQrBitmap(content).asImageBitmap() }
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
@@ -76,6 +79,7 @@ internal fun QrDisplayCard(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// ── QR image ─────────────────────────────────────────────────────────
Image(
bitmap = bitmap,
contentDescription = contentDescription,
@@ -85,39 +89,72 @@ internal fun QrDisplayCard(
.clip(RoundedCornerShape(12.dp))
)
// ── Action row: Share | Copy | Brightness | NFC ───────────────────────
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) },
modifier = Modifier.weight(1f)
// Share
IconButton(
onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) }
) {
Icon(Icons.Filled.Share, contentDescription = "Share",
modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Share")
Icon(
imageVector = Icons.Filled.Share,
contentDescription = "Share",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
OutlinedButton(
onClick = {
// Copy
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(clipLabel, textToCopy))
)
}
},
modifier = Modifier.weight(1f)
}
) {
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy",
modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Copy")
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Brightness
FilledTonalIconButton(
onClick = onToggleBrightness
) {
Icon(
imageVector = Icons.Filled.WbSunny,
contentDescription = if (brightnessOn) "Reduce brightness" else "Boost brightness",
tint = if (brightnessOn) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// NFC — only shown when caller passes isNfcEmulating
if (isNfcEmulating != null && onToggleNfc != null) {
FilledTonalIconButton(
onClick = onToggleNfc,
enabled = nfcEnabled
) {
Icon(
imageVector = if (isNfcEmulating) Icons.Filled.Nfc else Icons.Outlined.Nfc,
contentDescription = if (isNfcEmulating) "Stop NFC sharing" else "Share via NFC",
tint = if (isNfcEmulating) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
BrightnessToggleButton(isOn = brightnessOn, onToggle = onToggleBrightness)
}
}
}
// ── QR generation ────────────────────────────────────────────────────────────
private const val QR_BITMAP_SIZE = 512
private fun generateQrBitmap(content: String): Bitmap {
@@ -133,4 +170,3 @@ private fun generateQrBitmap(content: String): Bitmap {
this[x, y] = if (bits[x, y]) Color.BLACK else Color.WHITE
}
}
@@ -21,10 +21,14 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.outlined.Nfc
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -59,24 +63,26 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.util.fiatLabel
import kotlinx.coroutines.delay
@Composable
fun ReceiveScreen(
vm: WalletViewModel,
onNavigateToPaymentDetail : (checkingId: String) -> Unit
vm : WalletViewModel,
nfcVm : NfcViewModel, // ← new
onNavigateToPaymentDetail: (checkingId: String) -> Unit
) {
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
val fiatRate by vm.fiatSatsPerUnit.collectAsStateWithLifecycle()
val fiatCurrency by vm.selectedCurrency.collectAsStateWithLifecycle()
val context = LocalContext.current
val window = (context as Activity).window
val context = LocalContext.current
val window = (context as Activity).window
val originalBrightness = remember { window.attributes.screenBrightness }
var brightnessOn by remember { mutableStateOf(false) }
var showAddress by remember { mutableStateOf(false) }
var brightnessOn by remember { mutableStateOf(false) }
var showAddress by remember { mutableStateOf(false) }
val onToggleBrightness = {
val lp = window.attributes
@@ -87,15 +93,25 @@ fun ReceiveScreen(
brightnessOn = !brightnessOn
}
// Stop NFC emulation and reset receive state when leaving the screen
DisposableEffect(Unit) {
onDispose {
val lp = window.attributes
lp.screenBrightness = originalBrightness
window.attributes = lp
nfcVm.stopEmulating() // ← new: clean up HCE on exit
vm.resetReceiveState()
}
}
// Also stop emulating if the invoice is reset mid-session
// (e.g. user taps "Cancel" or "New Invoice")
LaunchedEffect(receiveState) {
if (receiveState !is ReceiveState.InvoiceReady) {
nfcVm.stopEmulating()
}
}
Box(modifier = Modifier.fillMaxSize()) {
if (receiveState !is ReceiveState.InvoiceReady) {
Column(
@@ -188,11 +204,15 @@ fun ReceiveScreen(
fiatCurrency = fiatCurrency,
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness,
nfcVm = nfcVm, // ← new
onReset = { vm.resetReceiveState() }
)
}
}
}
// ── LnurlWithdrawSheet (unchanged) ───────────────────────────────────────────
@Composable
private fun LnurlWithdrawSheet(
state : ReceiveState.LnurlWithdrawReady,
@@ -309,8 +329,13 @@ private fun LnurlWithdrawSheet(
}
}
}
// ── AmountUnit (unchanged) ────────────────────────────────────────────────────
private enum class AmountUnit { SATS, FIAT }
// ── ReceiveIdleContent (unchanged) ───────────────────────────────────────────
@Composable
private fun ReceiveIdleContent(
fiatRate : Double?,
@@ -323,13 +348,11 @@ private fun ReceiveIdleContent(
isLoading : Boolean,
onCreateInvoice : (Long, String) -> Unit
) {
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
// Wrap the toggle so that opening the address card always clears focus + keyboard
val onToggleAddressWithDismiss = {
if (!showAddress) {
// About to show address — dismiss keyboard and clear focus first
keyboardController?.hide()
focusManager.clearFocus()
}
@@ -347,7 +370,6 @@ private fun ReceiveIdleContent(
) {
Spacer(Modifier.height(8.dp))
// ── Header row ────────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -370,7 +392,6 @@ private fun ReceiveIdleContent(
}
}
// ── Lightning address card ────────────────────────────────────────────
AnimatedVisibility(visible = showAddress && lightningAddress != null) {
Card(
colors = CardDefaults.cardColors(
@@ -417,7 +438,7 @@ private fun ReceiveIdleContent(
fiatRate = fiatRate,
fiatCurrency = fiatCurrency,
addressShowing = showAddress,
onCollapseAddress = onToggleAddressWithDismiss, // ← consistent: same wrapper
onCollapseAddress = onToggleAddressWithDismiss,
onNext = { text, unit ->
confirmedAmountText = text
confirmedUnit = unit
@@ -439,6 +460,9 @@ private fun ReceiveIdleContent(
}
}
}
// ── AmountStepContent (unchanged) ────────────────────────────────────────────
@Composable
private fun AmountStepContent(
fiatRate : Double?,
@@ -485,8 +509,8 @@ private fun AmountStepContent(
placeholder = {
Text(
if (activeUnit == AmountUnit.SATS || !showFiatToggle) "0" else "0.00",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.End,
fontFamily = FontFamily.Monospace
)
},
@@ -497,7 +521,7 @@ private fun AmountStepContent(
else -> null
},
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.End,
textAlign = TextAlign.End,
fontFamily = FontFamily.Monospace
),
keyboardOptions = KeyboardOptions(
@@ -536,6 +560,9 @@ private fun AmountStepContent(
}
}
}
// ── MemoStepContent (unchanged) ──────────────────────────────────────────────
@Composable
private fun MemoStepContent(
confirmedSats : Long,
@@ -578,6 +605,9 @@ private fun MemoStepContent(
}
}
}
// ── ReceiveInvoiceContent — NFC toggle added ──────────────────────────────────
@Composable
private fun ReceiveInvoiceContent(
state : ReceiveState.InvoiceReady,
@@ -585,8 +615,11 @@ private fun ReceiveInvoiceContent(
fiatCurrency : String?,
brightnessOn : Boolean,
onToggleBrightness : () -> Unit,
nfcVm : NfcViewModel,
onReset : () -> Unit
) {
val isEmulating by nfcVm.isNfcEmulating.collectAsStateWithLifecycle()
var secondsLeft by remember {
mutableLongStateOf(
(state.expiresAt - System.currentTimeMillis() / 1000L).coerceAtLeast(0L)
@@ -622,9 +655,7 @@ private fun ReceiveInvoiceContent(
)
}
// ── QR — weight(1f) claims all remaining vertical space ───────────────
// modifier targets the QrDisplayCard's outer Column (gives it the height)
// qrModifier targets the Image inside (fills that height, stays square)
// ── QR + action row (Share / Copy / Brightness / NFC) ─────────────────
QrDisplayCard(
content = state.bolt11.uppercase(),
contentDescription = "Lightning invoice QR code",
@@ -633,23 +664,30 @@ private fun ReceiveInvoiceContent(
textToCopy = state.bolt11,
brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness,
isNfcEmulating = isEmulating,
onToggleNfc = {
if (isEmulating) nfcVm.stopEmulating()
else nfcVm.startEmulating("lightning:${state.bolt11}")
},
nfcEnabled = !isExpired,
modifier = Modifier
.weight(1f) // outer Column gets all remaining height
.weight(1f)
.fillMaxWidth(),
qrModifier = Modifier
.fillMaxWidth() // Image fills the column width
.weight(1f) // Image also takes remaining height inside QrDisplayCard's Column
.fillMaxWidth()
.weight(1f)
)
// ── Status ────────────────────────────────────────────────────────────
// ── Status row — restored to original simple format ───────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) {
if (!isExpired) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text(
text = "Waiting for payment",
text = "Waiting…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -676,6 +714,9 @@ private fun ReceiveInvoiceContent(
}
}
// ── ReceiveSuccessContent (unchanged) ────────────────────────────────────────
@Composable
private fun ReceiveSuccessContent(
amountSats : Long,
@@ -701,7 +742,6 @@ private fun ReceiveSuccessContent(
Spacer(Modifier.height(16.dp))
Text("Payment Received!", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
// AmountDisplay renders both the sats line and the fiat subtitle
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
if (!memo.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
+1
View File
@@ -1,3 +1,4 @@
<resources>
<string name="app_name">Gudari Wallet</string>
<string name="nfc_hce_description">Gudari Wallet NFC payment sharing</string>
</resources>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<host-apdu-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/nfc_hce_description"
android:requireDeviceUnlock="false">
<aid-group
android:description="@string/nfc_hce_description"
android:category="other">
<!--
NFC Forum Type 4 Tag AID.
Any NFC reader that supports NDEF will SELECT this AID first.
-->
<aid-filter android:name="D2760000850101" />
</aid-group>
</host-apdu-service>