diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a22b927..368cc91 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,11 +7,12 @@
+
-
+
+ android:foregroundServiceType="specialUse"
+ android:exported="false">
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt
index ccdc842..f2300c3 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt
@@ -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(null)
// Holds a payment URI that arrived via deep link / intent (BTCPay, external apps).
- private val pendingPaymentUri = mutableStateOf(null)
+ private val pendingPaymentUri = mutableStateOf(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(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))
}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt
index 067962b..38d7fed 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt
@@ -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
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt
index 7375523..ad1b100 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt
@@ -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,
pendingPaymentUri : MutableState
) {
@@ -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")
}
}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt
index c04b937..3b8332e 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt
@@ -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 get() = balanceVm.balanceState
val balanceHidden: StateFlow get() = fiatVm.balanceHidden
@@ -77,17 +75,6 @@ class WalletViewModel(
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
- val nfcOfferState: StateFlow = 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 get() = nfcVm.broadcastState
- fun startNfcBroadcast(payload: String) = nfcVm.startBroadcast(payload)
- fun cancelNfcBroadcast() = nfcVm.cancelBroadcast()
- fun dismissNfcBroadcastResult() = nfcVm.dismissBroadcastResult()
-
init {
observePaymentEvents()
fetchLightningAddress()
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt
index 61f0e0a..f1632cd 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt
@@ -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
}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/clipboard/ClipboardViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/clipboard/ClipboardViewModel.kt
index 3c91375..c9ff0ad 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/clipboard/ClipboardViewModel.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/clipboard/ClipboardViewModel.kt
@@ -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"
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NdefHceService.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NdefHceService.kt
new file mode 100644
index 0000000..0aeced1
--- /dev/null
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NdefHceService.kt
@@ -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) }
+}
+
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt
index 3790d9c..3ed09f8 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt
@@ -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.None)
- val nfcOfferState: StateFlow = _nfcOfferState
+ private val _isNfcEmulating = MutableStateFlow(false)
+ val isNfcEmulating: StateFlow = _isNfcEmulating
- private val _broadcastState = MutableStateFlow(NfcBroadcastState.Idle)
- val broadcastState: StateFlow = _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.None)
+ val nfcOfferState: StateFlow = _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 1–5 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) }
}
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt
index ab8ed22..d207a0a 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/QrDisplayCard.kt
@@ -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
}
}
-
diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt
index c71fb91..5dd6538 100644
--- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt
+++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/receive/ReceiveScreen.kt
@@ -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))
@@ -716,4 +756,4 @@ private fun ReceiveSuccessContent(
Text("Done")
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 02a1baf..a1407d0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,4 @@
Gudari Wallet
+ Gudari Wallet NFC payment sharing
\ No newline at end of file
diff --git a/app/src/main/res/xml/apduservice.xml b/app/src/main/res/xml/apduservice.xml
new file mode 100644
index 0000000..77b76ee
--- /dev/null
+++ b/app/src/main/res/xml/apduservice.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+