feat: add nfc step 2 - receive with invoice
This commit is contained in:
@@ -7,11 +7,12 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.nfc" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -55,8 +56,24 @@
|
||||
|
||||
<service
|
||||
android:name=".service.WalletNotificationService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:exported="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains a persistent Lightning Network connection to receive payment notifications" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".ui.nfc.NdefHceService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||
android:resource="@xml/apduservice" />
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.bitcointxoko.gudariwallet
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.Tag
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
@@ -25,6 +28,7 @@ import com.bitcointxoko.gudariwallet.ui.OnboardingScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.WalletScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.WalletViewModelFactory
|
||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.theme.GudariWalletTheme
|
||||
import com.bitcointxoko.gudariwallet.util.NotificationHelper
|
||||
import com.bitcointxoko.gudariwallet.util.IntentUtils
|
||||
@@ -40,6 +44,23 @@ 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.
|
||||
@@ -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,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)
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
|
||||
// Notification tap → navigate to payment detail
|
||||
val hash = intent.getStringExtra(NotificationHelper.EXTRA_PAYMENT_HASH)
|
||||
if (hash != null) {
|
||||
pendingPaymentHash.value = hash
|
||||
}
|
||||
|
||||
// Deep link / external app URI
|
||||
val uri = IntentUtils.extractPaymentUri(intent)
|
||||
if (uri != null) {
|
||||
pendingPaymentUri.value = uri
|
||||
}
|
||||
|
||||
// NFC tag discovered → hand off to NfcViewModel for reading
|
||||
val tag = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
|
||||
if (tag != null) {
|
||||
nfcVm.onTagDiscovered(tag)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ── Permission / battery helpers (unchanged) ──────────────────────────────
|
||||
|
||||
@@ -154,7 +209,8 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
if (tryStartActivity(appDetailsIntent)) return
|
||||
|
||||
+5
-10
@@ -104,17 +104,12 @@ class WalletNotificationService : Service() {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// Step 1: Call startForeground immediately — Android requires this within
|
||||
// 5 seconds of startForegroundService() or the app is ANR'd.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NotificationConstants.NOTIF_ID_SERVICE,
|
||||
NotificationHelper.buildServiceNotification(this),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34
|
||||
startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(
|
||||
NotificationConstants.NOTIF_ID_SERVICE,
|
||||
NotificationHelper.buildServiceNotification(this)
|
||||
)
|
||||
startForeground(NotificationConstants.NOTIF_ID_SERVICE, NotificationHelper.buildServiceNotification(this))
|
||||
}
|
||||
|
||||
// Step 2: Open the WebSocket. If already open (e.g. service restarted
|
||||
|
||||
@@ -58,6 +58,7 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
|
||||
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||
@@ -78,6 +79,7 @@ private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
|
||||
@Composable
|
||||
fun WalletScreen(
|
||||
vm : WalletViewModel,
|
||||
nfcVm : NfcViewModel, // ← new
|
||||
pendingPaymentHash: MutableState<String?>,
|
||||
pendingPaymentUri : MutableState<String?>
|
||||
) {
|
||||
@@ -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")
|
||||
}
|
||||
@@ -341,7 +324,6 @@ fun WalletScreen(
|
||||
ROUTE_HISTORY,
|
||||
enterTransition = { fadeIn(animationSpec = tween(150)) },
|
||||
popExitTransition = { fadeOut(animationSpec = tween(150)) }
|
||||
|
||||
) {
|
||||
HistoryScreen(
|
||||
vm = historyVm,
|
||||
@@ -393,7 +375,7 @@ fun WalletScreen(
|
||||
exit = slideOutVertically(targetOffsetY = { -it }),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.zIndex(1f)
|
||||
.zIndex(2f) // above NFC banner
|
||||
.fillMaxWidth()
|
||||
.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(
|
||||
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
|
||||
@@ -471,15 +454,12 @@ fun WalletScreen(
|
||||
else -> "Tap to pay"
|
||||
}
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer, // distinct from clipboard's primaryContainer
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
vm.dismissNfcOffer()
|
||||
nfcVm.dismissNfcOffer()
|
||||
vm.scan(offer.raw)
|
||||
// scan() already emits navigateToReceive for LNURL-withdraw,
|
||||
// so we only need to navigate to Send for everything else.
|
||||
// The existing navigateToReceive LaunchedEffect handles BoltCard.
|
||||
navController.navigate(TabItem.Send.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
@@ -489,7 +469,7 @@ fun WalletScreen(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Filled.Nfc, contentDescription = null)
|
||||
Icon(Icons.Default.Nfc, contentDescription = null)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
@@ -502,7 +482,7 @@ fun WalletScreen(
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { vm.dismissNfcOffer() }) {
|
||||
IconButton(onClick = { nfcVm.dismissNfcOffer() }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Dismiss")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.bitcointxoko.gudariwallet.data.LightningAddressStore
|
||||
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -35,8 +34,7 @@ class WalletViewModel(
|
||||
val fiatVm : FiatViewModel,
|
||||
val receiveVm : ReceiveViewModel,
|
||||
val sendVm : SendViewModel,
|
||||
val clipboardVm : ClipboardViewModel,
|
||||
val nfcVm : NfcViewModel
|
||||
val clipboardVm : ClipboardViewModel
|
||||
) : ViewModel() {
|
||||
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
|
||||
val balanceHidden: StateFlow<Boolean> get() = fiatVm.balanceHidden
|
||||
@@ -77,17 +75,6 @@ class WalletViewModel(
|
||||
fun checkClipboard(text: String?) = clipboardVm.checkClipboard(text)
|
||||
fun dismissClipboardOffer() = clipboardVm.dismissClipboardOffer()
|
||||
|
||||
val nfcOfferState: StateFlow<NfcOfferState> = nfcVm.nfcOfferState
|
||||
|
||||
fun onNfcTagDiscovered(tag: android.nfc.Tag) = nfcVm.onTagDiscovered(tag)
|
||||
@VisibleForTesting
|
||||
fun onNfcTagRead(text: String?) = nfcVm.onNfcTagRead(text)
|
||||
fun dismissNfcOffer() = nfcVm.dismissNfcOffer()
|
||||
val nfcBroadcastState: StateFlow<NfcViewModel.NfcBroadcastState> get() = nfcVm.broadcastState
|
||||
fun startNfcBroadcast(payload: String) = nfcVm.startBroadcast(payload)
|
||||
fun cancelNfcBroadcast() = nfcVm.cancelBroadcast()
|
||||
fun dismissNfcBroadcastResult() = nfcVm.dismissBroadcastResult()
|
||||
|
||||
init {
|
||||
observePaymentEvents()
|
||||
fetchLightningAddress()
|
||||
|
||||
@@ -50,10 +50,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
||||
ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 },
|
||||
ownAddress = { lnAddressStore.currentAddress }
|
||||
)
|
||||
val nfcVm = NfcViewModel(
|
||||
ownInvoice = { (receiveVm.receiveState.value as? ReceiveState.InvoiceReady)?.bolt11 },
|
||||
ownAddress = { lnAddressStore.currentAddress }
|
||||
)
|
||||
|
||||
balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() }
|
||||
|
||||
@@ -68,7 +64,6 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
|
||||
receiveVm = receiveVm,
|
||||
sendVm = sendVm,
|
||||
clipboardVm = clipboardVm,
|
||||
nfcVm = nfcVm,
|
||||
lnAddressStore = lnAddressStore,
|
||||
) as T
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@ package com.bitcointxoko.gudariwallet.ui.clipboard
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.ClipboardOfferState
|
||||
import com.bitcointxoko.gudariwallet.ui.ReceiveState
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow as SF
|
||||
|
||||
private const val TAG = "ClipboardViewModel"
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.bitcointxoko.gudariwallet.ui.nfc
|
||||
|
||||
import android.nfc.cardemulation.HostApduService
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* HCE service that emulates an NFC Forum Type 4 Tag containing an NDEF
|
||||
* message. The NDEF message holds a URI record (lightning: or bitcoin: URI)
|
||||
* set by NfcViewModel via the companion-object singleton slot.
|
||||
*
|
||||
* Protocol reference: NFC Forum Type 4 Tag Operation Specification.
|
||||
* The reader performs:
|
||||
* 1. SELECT NDEF Application (AID D2760000850101)
|
||||
* 2. SELECT Capability Container file (file ID 0xE103)
|
||||
* 3. READ BINARY of CC file
|
||||
* 4. SELECT NDEF file (file ID from CC)
|
||||
* 5. READ BINARY of NDEF file (length prefix + NDEF message bytes)
|
||||
*/
|
||||
class NdefHceService : HostApduService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NdefHceService"
|
||||
|
||||
// ── APDU status words ────────────────────────────────────────────────
|
||||
private val SW_OK = byteArrayOf(0x90.toByte(), 0x00)
|
||||
private val SW_FILE_NOT_FOUND = byteArrayOf(0x6A.toByte(), 0x82.toByte())
|
||||
private val SW_UNKNOWN = byteArrayOf(0x6F.toByte(), 0x00)
|
||||
|
||||
// ── File IDs ─────────────────────────────────────────────────────────
|
||||
private val CC_FILE_ID = byteArrayOf(0xE1.toByte(), 0x03.toByte())
|
||||
private val NDEF_FILE_ID = byteArrayOf(0xE1.toByte(), 0x04.toByte())
|
||||
|
||||
// ── NDEF Application AID ─────────────────────────────────────────────
|
||||
private val NDEF_AID = byteArrayOf(
|
||||
0xD2.toByte(), 0x76.toByte(), 0x00.toByte(), 0x00.toByte(),
|
||||
0x85.toByte(), 0x01.toByte(), 0x01.toByte()
|
||||
)
|
||||
|
||||
// ── Capability Container (CC) file ───────────────────────────────────
|
||||
// Standard 15-byte CC for a Type 4 Tag:
|
||||
// Bytes 0-1 : CC length (0x000F = 15)
|
||||
// Byte 2 : Mapping version (0x20 = v2.0)
|
||||
// Bytes 3-4 : Max R-APDU data size (0x007F = 127)
|
||||
// Bytes 5-6 : Max C-APDU data size (0x007F = 127)
|
||||
// Bytes 7-8 : NDEF File Control TLV tag+length (0x04, 0x06)
|
||||
// Bytes 9-10: NDEF file ID (0xE104)
|
||||
// Bytes 11-12: Max NDEF file size (0x0400 = 1024)
|
||||
// Byte 13 : Read access (0x00 = open)
|
||||
// Byte 14 : Write access (0xFF = no write)
|
||||
private val CC_FILE = byteArrayOf(
|
||||
0x00, 0x0F,
|
||||
0x20,
|
||||
0x00, 0x7F,
|
||||
0x00, 0x7F,
|
||||
0x04, 0x06,
|
||||
0xE1.toByte(), 0x04,
|
||||
0x04, 0x00,
|
||||
0x00,
|
||||
0xFF.toByte()
|
||||
)
|
||||
|
||||
// ── Shared NDEF payload slot ─────────────────────────────────────────
|
||||
// Written by NfcViewModel, read by processCommandApdu().
|
||||
// Volatile is sufficient — single writer, single reader on binder thread.
|
||||
@Volatile
|
||||
var ndefFileBytes: ByteArray? = null
|
||||
}
|
||||
|
||||
// Which file is currently selected (null = NDEF app selected, no file yet)
|
||||
private var selectedFileId: ByteArray? = null
|
||||
|
||||
override fun processCommandApdu(apdu: ByteArray, extras: Bundle?): ByteArray {
|
||||
Log.d(TAG, "APDU IN: ${apdu.toHex()}")
|
||||
|
||||
if (apdu.size < 4) return SW_UNKNOWN
|
||||
|
||||
val ins = apdu[1]
|
||||
|
||||
return when {
|
||||
// ── SELECT (INS = 0xA4) ──────────────────────────────────────────
|
||||
ins == 0xA4.toByte() -> handleSelect(apdu)
|
||||
|
||||
// ── READ BINARY (INS = 0xB0) ─────────────────────────────────────
|
||||
ins == 0xB0.toByte() -> handleReadBinary(apdu)
|
||||
|
||||
else -> SW_UNKNOWN
|
||||
}.also { Log.d(TAG, "APDU OUT: ${it.toHex()}") }
|
||||
}
|
||||
|
||||
private fun handleSelect(apdu: ByteArray): ByteArray {
|
||||
if (apdu.size < 5) return SW_UNKNOWN
|
||||
val dataLen = apdu[4].toInt() and 0xFF
|
||||
if (apdu.size < 5 + dataLen) return SW_UNKNOWN
|
||||
val data = apdu.copyOfRange(5, 5 + dataLen)
|
||||
|
||||
return when {
|
||||
data.contentEquals(NDEF_AID) -> {
|
||||
selectedFileId = null // app selected, no file yet
|
||||
Log.d(TAG, "SELECT NDEF Application OK")
|
||||
SW_OK
|
||||
}
|
||||
data.contentEquals(CC_FILE_ID) -> {
|
||||
selectedFileId = CC_FILE_ID
|
||||
Log.d(TAG, "SELECT CC file OK")
|
||||
SW_OK
|
||||
}
|
||||
data.contentEquals(NDEF_FILE_ID) -> {
|
||||
selectedFileId = NDEF_FILE_ID
|
||||
Log.d(TAG, "SELECT NDEF file OK")
|
||||
SW_OK
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "SELECT unknown file: ${data.toHex()}")
|
||||
SW_FILE_NOT_FOUND
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReadBinary(apdu: ByteArray): ByteArray {
|
||||
if (apdu.size < 5) return SW_UNKNOWN
|
||||
val offset = ((apdu[2].toInt() and 0xFF) shl 8) or (apdu[3].toInt() and 0xFF)
|
||||
val length = apdu[4].toInt() and 0xFF
|
||||
|
||||
val fileData: ByteArray = when {
|
||||
selectedFileId != null && selectedFileId!!.contentEquals(CC_FILE_ID) ->
|
||||
CC_FILE
|
||||
selectedFileId != null && selectedFileId!!.contentEquals(NDEF_FILE_ID) -> {
|
||||
ndefFileBytes ?: run {
|
||||
Log.w(TAG, "READ BINARY: no NDEF data loaded")
|
||||
return SW_FILE_NOT_FOUND
|
||||
}
|
||||
}
|
||||
else -> return SW_FILE_NOT_FOUND
|
||||
}
|
||||
|
||||
if (offset >= fileData.size) return SW_FILE_NOT_FOUND
|
||||
val end = minOf(offset + length, fileData.size)
|
||||
val chunk = fileData.copyOfRange(offset, end)
|
||||
return chunk + SW_OK
|
||||
}
|
||||
|
||||
override fun onDeactivated(reason: Int) {
|
||||
Log.d(TAG, "HCE deactivated, reason=$reason")
|
||||
selectedFileId = null
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────
|
||||
private fun ByteArray.toHex(): String =
|
||||
joinToString("") { "%02X".format(it) }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// ui/nfc/NfcViewModel.kt
|
||||
package com.bitcointxoko.gudariwallet.ui.nfc
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.NdefMessage
|
||||
import android.nfc.NdefRecord
|
||||
import android.nfc.Tag
|
||||
import android.nfc.tech.IsoDep
|
||||
import android.nfc.tech.Ndef
|
||||
import android.nfc.tech.NdefFormatable
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.nfc.NdefHceService
|
||||
import com.bitcointxoko.gudariwallet.ui.NfcOfferState
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputDetector
|
||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||
@@ -16,129 +20,60 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
private const val TAG = "NfcViewModel"
|
||||
|
||||
class NfcViewModel(
|
||||
// Same lambda pattern as ClipboardViewModel — no peer VM dependencies.
|
||||
private val ownInvoice : () -> String?,
|
||||
private val ownAddress : () -> String?,
|
||||
) : ViewModel() {
|
||||
class NfcViewModel : ViewModel() {
|
||||
|
||||
// ── Broadcast state ───────────────────────────────────────────────────────────
|
||||
// ── NFC receive (HCE emulation) ──────────────────────────────────────────
|
||||
|
||||
sealed interface NfcBroadcastState {
|
||||
data object Idle : NfcBroadcastState
|
||||
data object WaitingForTap : NfcBroadcastState // button pressed, awaiting tag
|
||||
data object Success : NfcBroadcastState
|
||||
data class Error(val message: String) : NfcBroadcastState
|
||||
}
|
||||
private val _nfcOfferState = MutableStateFlow<NfcOfferState>(NfcOfferState.None)
|
||||
val nfcOfferState: StateFlow<NfcOfferState> = _nfcOfferState
|
||||
private val _isNfcEmulating = MutableStateFlow(false)
|
||||
val isNfcEmulating: StateFlow<Boolean> = _isNfcEmulating
|
||||
|
||||
private val _broadcastState = MutableStateFlow<NfcBroadcastState>(NfcBroadcastState.Idle)
|
||||
val broadcastState: StateFlow<NfcBroadcastState> = _broadcastState
|
||||
private var pendingBroadcastPayload: String? = null
|
||||
|
||||
fun startBroadcast(payload: String) {
|
||||
pendingBroadcastPayload = payload
|
||||
_broadcastState.value = NfcBroadcastState.WaitingForTap
|
||||
Log.d(TAG, "startBroadcast: waiting for tag tap, payload=$payload")
|
||||
}
|
||||
|
||||
fun cancelBroadcast() {
|
||||
pendingBroadcastPayload = null
|
||||
_broadcastState.value = NfcBroadcastState.Idle
|
||||
}
|
||||
|
||||
fun dismissBroadcastResult() {
|
||||
_broadcastState.value = NfcBroadcastState.Idle
|
||||
/**
|
||||
* Start emulating an NFC tag containing [uri] (e.g. "lightning:lnbc...").
|
||||
* Builds the NDEF file bytes and loads them into NdefHceService.
|
||||
*/
|
||||
fun startEmulating(uri: String) {
|
||||
Log.d(TAG, "startEmulating: $uri")
|
||||
NdefHceService.ndefFileBytes = buildNdefFileBytes(uri)
|
||||
_isNfcEmulating.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from onTagDiscovered. If a broadcast is pending, write to the tag
|
||||
* instead of treating it as an incoming payment offer.
|
||||
* Returns true if the tag was consumed for writing (caller should not process
|
||||
* it as an incoming offer).
|
||||
* Stop emulating — clears the NDEF payload from the HCE service.
|
||||
*/
|
||||
fun tryWriteToTag(tag: Tag): Boolean {
|
||||
val payload = pendingBroadcastPayload ?: return false
|
||||
|
||||
val record = if (payload.startsWith("http://") || payload.startsWith("https://")
|
||||
|| payload.startsWith("lnurl") || payload.startsWith("bitcoin:")
|
||||
) {
|
||||
// URI record
|
||||
NdefRecord.createUri(payload)
|
||||
} else {
|
||||
// Text record (bolt11, lightning address, etc.)
|
||||
NdefRecord.createTextRecord("en", payload)
|
||||
fun stopEmulating() {
|
||||
Log.d(TAG, "stopEmulating")
|
||||
NdefHceService.ndefFileBytes = null
|
||||
_isNfcEmulating.value = false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopEmulating()
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
// 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) }
|
||||
}
|
||||
|
||||
@@ -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 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 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,38 +89,71 @@ 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(
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
BrightnessToggleButton(isOn = brightnessOn, onToggle = onToggleBrightness)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -133,4 +170,3 @@ private fun generateQrBitmap(content: String): Bitmap {
|
||||
this[x, y] = if (bits[x, y]) Color.BLACK else Color.WHITE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,14 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Nfc
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
import androidx.compose.material.icons.outlined.Nfc
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
@@ -59,12 +63,14 @@ 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,
|
||||
nfcVm : NfcViewModel, // ← new
|
||||
onNavigateToPaymentDetail: (checkingId: String) -> Unit
|
||||
) {
|
||||
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
|
||||
@@ -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?,
|
||||
@@ -326,10 +351,8 @@ private fun ReceiveIdleContent(
|
||||
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?,
|
||||
@@ -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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally)
|
||||
) {
|
||||
if (!isExpired) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Text(
|
||||
text = "Waiting for payment…",
|
||||
text = "Waiting…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
@@ -676,6 +714,9 @@ private fun ReceiveInvoiceContent(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── ReceiveSuccessContent (unchanged) ────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun ReceiveSuccessContent(
|
||||
amountSats : Long,
|
||||
@@ -701,7 +742,6 @@ private fun ReceiveSuccessContent(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Payment Received!", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// AmountDisplay renders both the sats line and the fiat subtitle
|
||||
AmountDisplay(amountSats = amountSats, fiatLabel = fiatLabel)
|
||||
if (!memo.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">Gudari Wallet</string>
|
||||
<string name="nfc_hce_description">Gudari Wallet NFC payment sharing</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user