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.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
|
||||||
|
|||||||
+5
-10
@@ -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 1–5 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,38 +89,71 @@ 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",
|
Icon(
|
||||||
modifier = Modifier.size(18.dp))
|
imageVector = Icons.Filled.ContentCopy,
|
||||||
Spacer(Modifier.width(6.dp))
|
contentDescription = "Copy",
|
||||||
Text("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
|
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
|
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,12 +63,14 @@ 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,
|
||||||
|
nfcVm : NfcViewModel, // ← new
|
||||||
onNavigateToPaymentDetail: (checkingId: String) -> Unit
|
onNavigateToPaymentDetail: (checkingId: String) -> Unit
|
||||||
) {
|
) {
|
||||||
val receiveState by vm.receiveState.collectAsStateWithLifecycle()
|
val receiveState by vm.receiveState.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,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>
|
||||||
@@ -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