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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.NFC" /> <uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" /> <uses-feature android:name="android.hardware.nfc" android:required="false" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -55,8 +56,24 @@
<service <service
android:name=".service.WalletNotificationService" android:name=".service.WalletNotificationService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="specialUse"
android:exported="false" /> 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> </application>
@@ -1,8 +1,11 @@
package com.bitcointxoko.gudariwallet package com.bitcointxoko.gudariwallet
import android.Manifest import android.Manifest
import android.app.PendingIntent
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager 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.WalletScreen
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.theme.GudariWalletTheme import com.bitcointxoko.gudariwallet.ui.theme.GudariWalletTheme
import com.bitcointxoko.gudariwallet.util.NotificationHelper import com.bitcointxoko.gudariwallet.util.NotificationHelper
import com.bitcointxoko.gudariwallet.util.IntentUtils import com.bitcointxoko.gudariwallet.util.IntentUtils
@@ -40,6 +44,23 @@ class MainActivity : AppCompatActivity() {
WalletViewModelFactory(application) 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. // Holds a paymentHash that arrived via notification tap.
// WalletScreen observes this and navigates to the detail screen. // WalletScreen observes this and navigates to the detail screen.
// MutableState so Compose recomposes when it changes. // MutableState so Compose recomposes when it changes.
@@ -73,10 +94,14 @@ class MainActivity : AppCompatActivity() {
NotificationHelper.createChannels(applicationContext) NotificationHelper.createChannels(applicationContext)
// Read hash from the intent that launched the activity (cold start from notification) // 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) // Read payment URI from the intent that launched the activity (cold start from deep link)
pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent) pendingPaymentUri.value = IntentUtils.extractPaymentUri(intent)
// Initialise NFC adapter (null on devices without NFC — handled gracefully)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
@@ -94,6 +119,7 @@ class MainActivity : AppCompatActivity() {
} }
WalletScreen( WalletScreen(
vm = vm, vm = vm,
nfcVm = nfcVm,
pendingPaymentHash = pendingPaymentHash, pendingPaymentHash = pendingPaymentHash,
pendingPaymentUri = pendingPaymentUri pendingPaymentUri = pendingPaymentUri
) )
@@ -105,25 +131,54 @@ 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) // (FLAG_ACTIVITY_SINGLE_TOP prevents a new instance being created)
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
// Notification tap → navigate to payment detail
val hash = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH) val hash = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
if (hash != null) { if (hash != null) {
pendingPaymentHash.value = hash pendingPaymentHash.value = hash
} }
// Deep link / external app URI
val uri = IntentUtils.extractPaymentUri(intent) val uri = IntentUtils.extractPaymentUri(intent)
if (uri != null) { if (uri != null) {
pendingPaymentUri.value = uri 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) ────────────────────────────── // ── Permission / battery helpers (unchanged) ──────────────────────────────
@@ -154,7 +209,8 @@ class MainActivity : AppCompatActivity() {
} }
private fun openBatterySettings() { private fun openBatterySettings() {
val appDetailsIntent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { val appDetailsIntent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri() data = "package:$packageName".toUri()
} }
if (tryStartActivity(appDetailsIntent)) return if (tryStartActivity(appDetailsIntent)) return
@@ -104,17 +104,12 @@ class WalletNotificationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Step 1: Call startForeground immediately — Android requires this within // Step 1: Call startForeground immediately — Android requires this within
// 5 seconds of startForegroundService() or the app is ANR'd. // 5 seconds of startForegroundService() or the app is ANR'd.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34
startForeground( startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
NotificationConstants.NOTIF_ID_SERVICE, } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
NotificationHelper.buildServiceNotification(this), startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else { } else {
startForeground( startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this))
NotificationConstants.NOTIF_ID_SERVICE,
NotificationHelper.buildServiceNotification(this)
)
} }
// Step 2: Open the WebSocket. If already open (e.g. service restarted // 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.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen 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.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
@@ -78,6 +79,7 @@ private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
@Composable @Composable
fun WalletScreen( fun WalletScreen(
vm : WalletViewModel, vm : WalletViewModel,
nfcVm : NfcViewModel, // ← new
pendingPaymentHash: MutableState<String?>, pendingPaymentHash: MutableState<String?>,
pendingPaymentUri : MutableState<String?> pendingPaymentUri : MutableState<String?>
) { ) {
@@ -143,7 +145,7 @@ fun WalletScreen(
LaunchedEffect(intentUri) { LaunchedEffect(intentUri) {
if (intentUri != null) { if (intentUri != null) {
Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri") Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri")
pendingPaymentUri.value = null // consume immediately pendingPaymentUri.value = null
vm.scan(intentUri) vm.scan(intentUri)
navController.navigate(TabItem.Send.route) { navController.navigate(TabItem.Send.route) {
launchSingleTop = true launchSingleTop = true
@@ -151,9 +153,9 @@ fun WalletScreen(
} }
} }
// ── Clipboard offer ───────────────────────────────────────────────────────
val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle() val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle()
// Auto-dismiss banner after 8 seconds
LaunchedEffect(clipboardOffer) { LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) { if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000) delay(8_000)
@@ -161,33 +163,13 @@ fun WalletScreen(
} }
} }
// ── NFC reader mode — register on composition, unregister on disposal ──────── // ── NFC offer (tag read while app is foregrounded) ────────────────────────
val nfcAdapter = remember { val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()
android.nfc.NfcAdapter.getDefaultAdapter(context)
}
DisposableEffect(Unit) {
val activity = context as MainActivity
nfcAdapter?.enableReaderMode(
activity,
{ tag -> vm.onNfcTagDiscovered(tag) }, // runs on NFC binder thread
android.nfc.NfcAdapter.FLAG_READER_NFC_A or
android.nfc.NfcAdapter.FLAG_READER_NFC_B or
android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK.inv(), // keep NDEF
null
)
onDispose {
nfcAdapter?.disableReaderMode(activity)
}
}
val nfcOffer by vm.nfcOfferState.collectAsStateWithLifecycle()
// Auto-dismiss NFC banner after 8 seconds (same as clipboard)
LaunchedEffect(nfcOffer) { LaunchedEffect(nfcOffer) {
if (nfcOffer is NfcOfferState.Detected) { if (nfcOffer is NfcOfferState.Detected) {
delay(8_000) delay(8_000)
vm.dismissNfcOffer() nfcVm.dismissNfcOffer()
} }
} }
@@ -304,7 +286,8 @@ fun WalletScreen(
} }
composable(TabItem.Receive.route) { composable(TabItem.Receive.route) {
ReceiveScreen( ReceiveScreen(
vm, vm = vm,
nfcVm = nfcVm, // ← new
onNavigateToPaymentDetail = { checkingId -> onNavigateToPaymentDetail = { checkingId ->
navController.navigate("payment_detail/$checkingId") navController.navigate("payment_detail/$checkingId")
} }
@@ -341,7 +324,6 @@ fun WalletScreen(
ROUTE_HISTORY, ROUTE_HISTORY,
enterTransition = { fadeIn(animationSpec = tween(150)) }, enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) } popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { ) {
HistoryScreen( HistoryScreen(
vm = historyVm, vm = historyVm,
@@ -393,7 +375,7 @@ fun WalletScreen(
exit = slideOutVertically(targetOffsetY = { -it }), exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.zIndex(1f) .zIndex(2f) // above NFC banner
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
) { ) {
@@ -445,18 +427,19 @@ 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( AnimatedVisibility(
visible = nfcOffer is NfcOfferState.Detected, visible = nfcOffer is NfcOfferState.Detected,
enter = slideInVertically(initialOffsetY = { -it }), enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }), exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.zIndex(1f) .zIndex(1f) // below clipboard banner if both show
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding() .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) { if (nfcOffer is NfcOfferState.Detected) {
val offer = nfcOffer as NfcOfferState.Detected val offer = nfcOffer as NfcOfferState.Detected
@@ -471,15 +454,12 @@ fun WalletScreen(
else -> "Tap to pay" else -> "Tap to pay"
} }
Surface( Surface(
color = MaterialTheme.colorScheme.secondaryContainer, // distinct from clipboard's primaryContainer color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
vm.dismissNfcOffer() nfcVm.dismissNfcOffer()
vm.scan(offer.raw) 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) { navController.navigate(TabItem.Send.route) {
launchSingleTop = true launchSingleTop = true
} }
@@ -489,7 +469,7 @@ fun WalletScreen(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Filled.Nfc, contentDescription = null) Icon(Icons.Default.Nfc, contentDescription = null)
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
@@ -502,7 +482,7 @@ fun WalletScreen(
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
) )
} }
IconButton(onClick = { vm.dismissNfcOffer() }) { IconButton(onClick = { nfcVm.dismissNfcOffer() }) {
Icon(Icons.Default.Close, contentDescription = "Dismiss") 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.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -35,8 +34,7 @@ class WalletViewModel(
val fiatVm : FiatViewModel, val fiatVm : FiatViewModel,
val receiveVm : ReceiveViewModel, val receiveVm : ReceiveViewModel,
val sendVm : SendViewModel, val sendVm : SendViewModel,
val clipboardVm : ClipboardViewModel, val clipboardVm : ClipboardViewModel
val nfcVm : NfcViewModel
) : ViewModel() { ) : ViewModel() {
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
val balanceHidden: StateFlow<Boolean> get() = fiatVm.balanceHidden val balanceHidden: StateFlow<Boolean> get() = fiatVm.balanceHidden
@@ -77,17 +75,6 @@ class WalletViewModel(
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text) fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer() fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
val nfcOfferState: StateFlow<NfcOfferState> = nfcVm.nfcOfferState
fun onNfcTagDiscovered(tag: android.nfc.Tag) = nfcVm.onTagDiscovered(tag)
@VisibleForTesting
fun onNfcTagRead(text: String?) = nfcVm.onNfcTagRead(text)
fun dismissNfcOffer() = nfcVm.dismissNfcOffer()
val nfcBroadcastState: StateFlow<NfcViewModel.NfcBroadcastState> get() = nfcVm.broadcastState
fun startNfcBroadcast(payload: String) = nfcVm.startBroadcast(payload)
fun cancelNfcBroadcast() = nfcVm.cancelBroadcast()
fun dismissNfcBroadcastResult() = nfcVm.dismissBroadcastResult()
init { init {
observePaymentEvents() observePaymentEvents()
fetchLightningAddress() fetchLightningAddress()
@@ -50,10 +50,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 }, ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 },
ownAddress = { lnAddressStore.currentAddress } ownAddress = { lnAddressStore.currentAddress }
) )
val nfcVm = NfcViewModel(
ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 },
ownAddress = { lnAddressStore.currentAddress }
)
balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() } balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() }
@@ -68,7 +64,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
receiveVm = receiveVm, receiveVm = receiveVm,
sendVm = sendVm, sendVm = sendVm,
clipboardVm = clipboardVm, clipboardVm = clipboardVm,
nfcVm = nfcVm,
lnAddressStore = lnAddressStore, lnAddressStore = lnAddressStore,
) as T ) as T
} }
@@ -3,12 +3,10 @@ package com.bitcointxoko.gudariwallet.ui.clipboard
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.bitcointxoko.gudariwallet.ui.ClipboardOfferState import com.bitcointxoko.gudariwallet.ui.ClipboardOfferState
import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.StateFlow as SF
private const val TAG = "ClipboardViewModel" 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 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.NdefMessage
import android.nfc.NdefRecord import android.nfc.NdefRecord
import android.nfc.Tag import android.nfc.Tag
import android.nfc.tech.IsoDep
import android.nfc.tech.Ndef import android.nfc.tech.Ndef
import android.nfc.tech.NdefFormatable import android.nfc.tech.NdefFormatable
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.bitcointxoko.gudariwallet.ui.nfc.NdefHceService
import com.bitcointxoko.gudariwallet.ui.NfcOfferState import com.bitcointxoko.gudariwallet.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
@@ -16,129 +20,60 @@ import kotlinx.coroutines.flow.StateFlow
private const val TAG = "NfcViewModel" private const val TAG = "NfcViewModel"
class NfcViewModel( class NfcViewModel : ViewModel() {
// Same lambda pattern as ClipboardViewModel — no peer VM dependencies.
private val ownInvoice : () -> String?,
private val ownAddress : () -> String?,
) : ViewModel() {
// ── Broadcast state ─────────────────────────────────────────────────────────── // ── NFC receive (HCE emulation) ──────────────────────────────────────────
sealed interface NfcBroadcastState { private val _isNfcEmulating = MutableStateFlow(false)
data object Idle : NfcBroadcastState val isNfcEmulating: StateFlow<Boolean> = _isNfcEmulating
data object WaitingForTap : NfcBroadcastState // button pressed, awaiting tag
data object Success : NfcBroadcastState
data class Error(val message: String) : NfcBroadcastState
}
private val _nfcOfferState = MutableStateFlow<NfcOfferState>(NfcOfferState.None)
val nfcOfferState: StateFlow<NfcOfferState> = _nfcOfferState
private val _broadcastState = MutableStateFlow<NfcBroadcastState>(NfcBroadcastState.Idle) /**
val broadcastState: StateFlow<NfcBroadcastState> = _broadcastState * Start emulating an NFC tag containing [uri] (e.g. "lightning:lnbc...").
private var pendingBroadcastPayload: String? = null * Builds the NDEF file bytes and loads them into NdefHceService.
*/
fun startBroadcast(payload: String) { fun startEmulating(uri: String) {
pendingBroadcastPayload = payload Log.d(TAG, "startEmulating: $uri")
_broadcastState.value = NfcBroadcastState.WaitingForTap NdefHceService.ndefFileBytes = buildNdefFileBytes(uri)
Log.d(TAG, "startBroadcast: waiting for tag tap, payload=$payload") _isNfcEmulating.value = true
}
fun cancelBroadcast() {
pendingBroadcastPayload = null
_broadcastState.value = NfcBroadcastState.Idle
}
fun dismissBroadcastResult() {
_broadcastState.value = NfcBroadcastState.Idle
} }
/** /**
* Called from onTagDiscovered. If a broadcast is pending, write to the tag * Stop emulating — clears the NDEF payload from the HCE service.
* instead of treating it as an incoming payment offer.
* Returns true if the tag was consumed for writing (caller should not process
* it as an incoming offer).
*/ */
fun tryWriteToTag(tag: Tag): Boolean { fun stopEmulating() {
val payload = pendingBroadcastPayload ?: return false Log.d(TAG, "stopEmulating")
NdefHceService.ndefFileBytes = null
val record = if (payload.startsWith("http://") || payload.startsWith("https://") _isNfcEmulating.value = false
|| 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)) override fun onCleared() {
super.onCleared()
return try { stopEmulating()
val ndef = Ndef.get(tag)
if (ndef != null) {
ndef.connect()
if (!ndef.isWritable) {
_broadcastState.value = NfcBroadcastState.Error("Tag is read-only")
return true
}
if (ndef.maxSize < message.toByteArray().size) {
_broadcastState.value = NfcBroadcastState.Error("Tag too small")
return true
}
ndef.writeNdefMessage(message)
ndef.close()
} else {
// Try to format an unformatted tag
val formatable = NdefFormatable.get(tag)
?: run {
_broadcastState.value = NfcBroadcastState.Error("Tag not NDEF compatible")
return true
}
formatable.connect()
formatable.format(message)
formatable.close()
}
pendingBroadcastPayload = null
_broadcastState.value = NfcBroadcastState.Success
Log.d(TAG, "tryWriteToTag: write successful")
true
} catch (e: Exception) {
Log.e(TAG, "tryWriteToTag: write failed", e)
_broadcastState.value = NfcBroadcastState.Error(e.message ?: "Write failed")
true
}
} }
// ── Called from WalletScreen's NFC reader-mode callback ────────────────── // ── 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) { fun onTagDiscovered(tag: Tag) {
// If a broadcast is pending, write to the tag and stop Log.d(TAG, "onTagDiscovered: ${tag.id?.toHex()}")
if (tryWriteToTag(tag)) return
// Otherwise, decode and offer as incoming payment
val raw = decodeNdefTag(tag)
Log.d(TAG, "onTagDiscovered decoded: $raw")
onNfcTagRead(raw)
}
fun onNfcTagRead(text: String?) { val text = readNdefText(tag) ?: run {
val trimmed = text?.trim().takeIf { !it.isNullOrBlank() } ?: return Log.w(TAG, "Could not read NDEF from tag")
// Guard: ignore our own invoice
val invoice = ownInvoice()
if (invoice != null && trimmed.equals(invoice, ignoreCase = true)) {
Log.d(TAG, "Skipping own invoice")
return
}
// Guard: ignore our own lightning address
val address = ownAddress()
if (address != null && trimmed.equals(address, ignoreCase = true)) {
Log.d(TAG, "Skipping own lightning address")
return return
} }
Log.d(TAG, "Tag NDEF text: $text")
val trimmed = text.trim()
val type = SendInputDetector.detect(trimmed) val type = SendInputDetector.detect(trimmed)
if (type == SendInputType.Unknown) { if (type == SendInputType.Unknown) {
Log.d(TAG, "Unknown input type, ignoring") Log.d(TAG, "Tag content not a recognised payment URI")
return return
} }
@@ -149,55 +84,118 @@ class NfcViewModel(
_nfcOfferState.value = NfcOfferState.None _nfcOfferState.value = NfcOfferState.None
} }
// ── NDEF decoding ───────────────────────────────────────────────────────── // ── NDEF reading ─────────────────────────────────────────────────────────
private fun decodeNdefTag(tag: Tag): String? {
val ndef = Ndef.get(tag) ?: return null private fun readNdefText(tag: Tag): String? {
return try { // Try standard NDEF first (covers Type 15 tags, NTAG21x, etc.)
Ndef.get(tag)?.use { ndef ->
ndef.connect() ndef.connect()
val message = ndef.ndefMessage ?: return null val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return null
val record = message.records.firstOrNull() ?: return null return extractUri(msg)
decodeNdefRecord(record)
} catch (e: Exception) {
Log.e(TAG, "NDEF read failed", e)
null
} finally {
runCatching { ndef.close() }
}
} }
private fun decodeNdefRecord(record: NdefRecord): String? = when (record.tnf) { // Try NdefFormatable (blank tags — nothing to read)
NdefRecord.TNF_WELL_KNOWN -> when { // Try IsoDep last (Type 4 / ISO-DEP tags like Bolt Cards)
// URI record (https://..., lnurl:..., bitcoin:..., etc.) IsoDep.get(tag)?.use { isoDep ->
record.type.contentEquals(NdefRecord.RTD_URI) -> isoDep.connect()
record.toUri()?.toString() isoDep.timeout = 3000
val msg = readNdefFromIsoDep(isoDep) ?: return null
// Plain text record (lnbc..., user@domain.com, etc.) return extractUri(msg)
record.type.contentEquals(NdefRecord.RTD_TEXT) ->
decodeTextRecord(record.payload)
else -> null
} }
// Absolute URI (some tags encode this way)
NdefRecord.TNF_ABSOLUTE_URI ->
String(record.payload, Charsets.UTF_8)
else -> null return null
} }
/** /**
* NDEF Text record payload layout: * Reads an NDEF message from an ISO-DEP (Type 4) tag by replaying the
* byte 0 : status byte (bit7 = UTF-16 flag, bits 5-0 = lang code length) * same APDU sequence our HCE service responds to.
* bytes 1..n : language code (e.g. "en")
* bytes n+1.. : the actual text
*/ */
private fun decodeTextRecord(payload: ByteArray): String? { private fun readNdefFromIsoDep(isoDep: IsoDep): NdefMessage? {
if (payload.isEmpty()) return null fun ByteArray.sw(): Int =
val statusByte = payload[0].toInt() and 0xFF ((this[size - 2].toInt() and 0xFF) shl 8) or (this[size - 1].toInt() and 0xFF)
val isUtf16 = (statusByte and 0x80) != 0 fun ByteArray.data(): ByteArray = copyOfRange(0, size - 2)
val langLength = statusByte and 0x3F fun ByteArray.isOk() = sw() == 0x9000
val textStart = 1 + langLength
if (textStart > payload.size) return null // 1. SELECT NDEF Application
val charset = if (isUtf16) Charsets.UTF_16 else Charsets.UTF_8 val selectApp = byteArrayOf(
return String(payload, textStart, payload.size - textStart, charset) 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.content.ClipData
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.Share 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.Icon
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import com.bitcointxoko.gudariwallet.util.IntentUtils import com.bitcointxoko.gudariwallet.util.IntentUtils
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.launch 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 content What to encode into the QR code.
* @param contentDescription Accessibility label for the image. * @param contentDescription Accessibility label for the QR image.
* @param clipLabel Label used when writing to the clipboard (e.g. "Lightning Address"). * @param clipLabel Label used when writing to the clipboard.
* @param shareTitle Chooser title for the share intent. * @param shareTitle Chooser title for the share intent.
* @param textToCopy The raw string placed on the clipboard and shared. * @param textToCopy The raw string placed on the clipboard and shared.
* @param brightnessOn Current brightness-boost state. * @param brightnessOn Current brightness-boost state.
* @param onToggleBrightness Called when the brightness button is tapped. * @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 @Composable
internal fun QrDisplayCard( internal fun QrDisplayCard(
@@ -62,11 +64,12 @@ internal fun QrDisplayCard(
textToCopy : String, textToCopy : String,
brightnessOn : Boolean, brightnessOn : Boolean,
onToggleBrightness : () -> Unit, 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) 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 context = LocalContext.current
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -76,6 +79,7 @@ internal fun QrDisplayCard(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// ── QR image ─────────────────────────────────────────────────────────
Image( Image(
bitmap = bitmap, bitmap = bitmap,
contentDescription = contentDescription, contentDescription = contentDescription,
@@ -85,39 +89,72 @@ internal fun QrDisplayCard(
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
) )
// ── Action row: Share | Copy | Brightness | NFC ───────────────────────
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) { ) {
OutlinedButton( // Share
onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) }, IconButton(
modifier = Modifier.weight(1f) onClick = { IntentUtils.shareText(context, textToCopy, shareTitle) }
) { ) {
Icon(Icons.Filled.Share, contentDescription = "Share", Icon(
modifier = Modifier.size(18.dp)) imageVector = Icons.Filled.Share,
Spacer(Modifier.width(6.dp)) contentDescription = "Share",
Text("Share") tint = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
OutlinedButton(
// Copy
IconButton(
onClick = { onClick = {
scope.launch { scope.launch {
clipboard.setClipEntry( clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(clipLabel, textToCopy)) 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")
} }
BrightnessToggleButton(isOn = brightnessOn, onToggle = onToggleBrightness) ) {
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
)
}
}
} }
} }
} }
// ── QR generation ────────────────────────────────────────────────────────────
private const val QR_BITMAP_SIZE = 512 private const val QR_BITMAP_SIZE = 512
private fun generateQrBitmap(content: String): Bitmap { 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 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.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download 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.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -59,13 +63,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.ui.ReceiveState import com.bitcointxoko.gudariwallet.ui.ReceiveState
import com.bitcointxoko.gudariwallet.ui.WalletViewModel import com.bitcointxoko.gudariwallet.ui.WalletViewModel
import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker import com.bitcointxoko.gudariwallet.ui.components.UnitWheelPicker
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.util.fiatLabel import com.bitcointxoko.gudariwallet.util.fiatLabel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@Composable @Composable
fun ReceiveScreen( fun ReceiveScreen(
vm: WalletViewModel, vm : WalletViewModel,
onNavigateToPaymentDetail : (checkingId: String) -> Unit nfcVm : NfcViewModel, // ← new
onNavigateToPaymentDetail: (checkingId: String) -> Unit
) { ) {
val receiveState by vm.receiveState.collectAsStateWithLifecycle() val receiveState by vm.receiveState.collectAsStateWithLifecycle()
val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle() val lightningAddress by vm.lightningAddress.collectAsStateWithLifecycle()
@@ -87,15 +93,25 @@ fun ReceiveScreen(
brightnessOn = !brightnessOn brightnessOn = !brightnessOn
} }
// Stop NFC emulation and reset receive state when leaving the screen
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
val lp = window.attributes val lp = window.attributes
lp.screenBrightness = originalBrightness lp.screenBrightness = originalBrightness
window.attributes = lp window.attributes = lp
nfcVm.stopEmulating() // ← new: clean up HCE on exit
vm.resetReceiveState() 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()) { Box(modifier = Modifier.fillMaxSize()) {
if (receiveState !is ReceiveState.InvoiceReady) { if (receiveState !is ReceiveState.InvoiceReady) {
Column( Column(
@@ -188,11 +204,15 @@ fun ReceiveScreen(
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
brightnessOn = brightnessOn, brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness, onToggleBrightness = onToggleBrightness,
nfcVm = nfcVm, // ← new
onReset = { vm.resetReceiveState() } onReset = { vm.resetReceiveState() }
) )
} }
} }
} }
// ── LnurlWithdrawSheet (unchanged) ───────────────────────────────────────────
@Composable @Composable
private fun LnurlWithdrawSheet( private fun LnurlWithdrawSheet(
state : ReceiveState.LnurlWithdrawReady, state : ReceiveState.LnurlWithdrawReady,
@@ -309,8 +329,13 @@ private fun LnurlWithdrawSheet(
} }
} }
} }
// ── AmountUnit (unchanged) ────────────────────────────────────────────────────
private enum class AmountUnit { SATS, FIAT } private enum class AmountUnit { SATS, FIAT }
// ── ReceiveIdleContent (unchanged) ───────────────────────────────────────────
@Composable @Composable
private fun ReceiveIdleContent( private fun ReceiveIdleContent(
fiatRate : Double?, fiatRate : Double?,
@@ -326,10 +351,8 @@ private fun ReceiveIdleContent(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
// Wrap the toggle so that opening the address card always clears focus + keyboard
val onToggleAddressWithDismiss = { val onToggleAddressWithDismiss = {
if (!showAddress) { if (!showAddress) {
// About to show address — dismiss keyboard and clear focus first
keyboardController?.hide() keyboardController?.hide()
focusManager.clearFocus() focusManager.clearFocus()
} }
@@ -347,7 +370,6 @@ private fun ReceiveIdleContent(
) { ) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// ── Header row ────────────────────────────────────────────────────────
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -370,7 +392,6 @@ private fun ReceiveIdleContent(
} }
} }
// ── Lightning address card ────────────────────────────────────────────
AnimatedVisibility(visible = showAddress && lightningAddress != null) { AnimatedVisibility(visible = showAddress && lightningAddress != null) {
Card( Card(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -417,7 +438,7 @@ private fun ReceiveIdleContent(
fiatRate = fiatRate, fiatRate = fiatRate,
fiatCurrency = fiatCurrency, fiatCurrency = fiatCurrency,
addressShowing = showAddress, addressShowing = showAddress,
onCollapseAddress = onToggleAddressWithDismiss, // ← consistent: same wrapper onCollapseAddress = onToggleAddressWithDismiss,
onNext = { text, unit -> onNext = { text, unit ->
confirmedAmountText = text confirmedAmountText = text
confirmedUnit = unit confirmedUnit = unit
@@ -439,6 +460,9 @@ private fun ReceiveIdleContent(
} }
} }
} }
// ── AmountStepContent (unchanged) ────────────────────────────────────────────
@Composable @Composable
private fun AmountStepContent( private fun AmountStepContent(
fiatRate : Double?, fiatRate : Double?,
@@ -536,6 +560,9 @@ private fun AmountStepContent(
} }
} }
} }
// ── MemoStepContent (unchanged) ──────────────────────────────────────────────
@Composable @Composable
private fun MemoStepContent( private fun MemoStepContent(
confirmedSats : Long, confirmedSats : Long,
@@ -578,6 +605,9 @@ private fun MemoStepContent(
} }
} }
} }
// ── ReceiveInvoiceContent — NFC toggle added ──────────────────────────────────
@Composable @Composable
private fun ReceiveInvoiceContent( private fun ReceiveInvoiceContent(
state : ReceiveState.InvoiceReady, state : ReceiveState.InvoiceReady,
@@ -585,8 +615,11 @@ private fun ReceiveInvoiceContent(
fiatCurrency : String?, fiatCurrency : String?,
brightnessOn : Boolean, brightnessOn : Boolean,
onToggleBrightness : () -> Unit, onToggleBrightness : () -> Unit,
nfcVm : NfcViewModel,
onReset : () -> Unit onReset : () -> Unit
) { ) {
val isEmulating by nfcVm.isNfcEmulating.collectAsStateWithLifecycle()
var secondsLeft by remember { var secondsLeft by remember {
mutableLongStateOf( mutableLongStateOf(
(state.expiresAt - System.currentTimeMillis() / 1000L).coerceAtLeast(0L) (state.expiresAt - System.currentTimeMillis() / 1000L).coerceAtLeast(0L)
@@ -622,9 +655,7 @@ private fun ReceiveInvoiceContent(
) )
} }
// ── QR — weight(1f) claims all remaining vertical space ─────────────── // ── QR + action row (Share / Copy / Brightness / NFC) ─────────────────
// modifier targets the QrDisplayCard's outer Column (gives it the height)
// qrModifier targets the Image inside (fills that height, stays square)
QrDisplayCard( QrDisplayCard(
content = state.bolt11.uppercase(), content = state.bolt11.uppercase(),
contentDescription = "Lightning invoice QR code", contentDescription = "Lightning invoice QR code",
@@ -633,23 +664,30 @@ private fun ReceiveInvoiceContent(
textToCopy = state.bolt11, textToCopy = state.bolt11,
brightnessOn = brightnessOn, brightnessOn = brightnessOn,
onToggleBrightness = onToggleBrightness, onToggleBrightness = onToggleBrightness,
isNfcEmulating = isEmulating,
onToggleNfc = {
if (isEmulating) nfcVm.stopEmulating()
else nfcVm.startEmulating("lightning:${state.bolt11}")
},
nfcEnabled = !isExpired,
modifier = Modifier modifier = Modifier
.weight(1f) // outer Column gets all remaining height .weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
qrModifier = Modifier qrModifier = Modifier
.fillMaxWidth() // Image fills the column width .fillMaxWidth()
.weight(1f) // Image also takes remaining height inside QrDisplayCard's Column .weight(1f)
) )
// ── Status ──────────────────────────────────────────────────────────── // ── Status row — restored to original simple format ───────────────────
Row( Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
) { ) {
if (!isExpired) { if (!isExpired) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Text( Text(
text = "Waiting for payment", text = "Waiting…",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -676,6 +714,9 @@ private fun ReceiveInvoiceContent(
} }
} }
// ── ReceiveSuccessContent (unchanged) ────────────────────────────────────────
@Composable @Composable
private fun ReceiveSuccessContent( private fun ReceiveSuccessContent(
amountSats : Long, amountSats : Long,
@@ -701,7 +742,6 @@ private fun ReceiveSuccessContent(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text("Payment Received!", style = MaterialTheme.typography.headlineSmall) Text("Payment Received!", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// AmountDisplay renders both the sats line and the fiat subtitle
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel) AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
if (!memo.isNullOrBlank()) { if (!memo.isNullOrBlank()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
+1
View File
@@ -1,3 +1,4 @@
<resources> <resources>
<string name="app_name">Gudari Wallet</string> <string name="app_name">Gudari Wallet</string>
<string name="nfc_hce_description">Gudari Wallet NFC payment sharing</string>
</resources> </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>