Skip to content

Commit 87a137c

Browse files
authored
(android) Use NFC to read/share payment requests (#722)
NFC is an alternative to scanning QR codes for sharing payment requests between devices. The app needs to be able to read tags to send, and emulate tags to receive. * Added a service to emulate a NDEF tag This service is manually started from the Receive screen. It emits a NDEF text record containing a payment request (LN or on-chain). When the service is started, a blocking dialog is displayed. * Use the NFC reader mode to read NFC-A tags To interact with an HCE device we need to use the NFC reader mode with the ReaderCallback method, and implement the apdu commands interaction with the tag, just like is done in the HCE service. To read a tag the user must tap a button in the Send screen.
1 parent 3b878c9 commit 87a137c

File tree

20 files changed

+992
-43
lines changed

20 files changed

+992
-43
lines changed

phoenix-android/src/main/AndroidManifest.xml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
33
xmlns:tools="http://schemas.android.com/tools">
44

5+
<!-- camera, not mandatory -->
56
<uses-feature
67
android:name="android.hardware.camera"
78
android:required="false" />
9+
<!-- nfc support, not mandatory -->
10+
<uses-feature
11+
android:name="android.hardware.nfc"
12+
android:required="false" />
13+
<!-- hce support, not mandatory-->
14+
<uses-feature android:name="android.hardware.nfc.hce"
15+
android:required="false"/>
816

917
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
1018
<uses-permission android:name="android.permission.INTERNET" />
1119
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
1220
<uses-permission android:name="android.permission.CAMERA" />
21+
<uses-permission android:name="android.permission.NFC" />
1322
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1423
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
1524
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -47,11 +56,13 @@
4756
<action android:name="android.intent.action.MAIN" />
4857
<category android:name="android.intent.category.LAUNCHER" />
4958
</intent-filter>
59+
5060
<!-- support for lightning/bitcoin/lnurl schemes -->
5161
<intent-filter>
5262
<action android:name="android.intent.action.VIEW" />
5363
<category android:name="android.intent.category.BROWSABLE" />
5464
<category android:name="android.intent.category.DEFAULT" />
65+
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
5566

5667
<!-- bitcoin & lightning schemes -->
5768
<data android:scheme="bitcoin" />
@@ -93,6 +104,20 @@
93104
android:exported="false"
94105
android:stopWithTask="false" />
95106

107+
<!-- apdu service for tag emulation -->
108+
<service android:name=".services.HceService"
109+
android:exported="true"
110+
android:enabled="true"
111+
android:permission="android.permission.BIND_NFC_SERVICE">
112+
<intent-filter>
113+
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
114+
<category android:name="android.intent.category.DEFAULT" />
115+
</intent-filter>
116+
<meta-data android:name="android.nfc.cardemulation.host_apdu_service"
117+
android:resource="@xml/apduservice" />
118+
119+
</service>
120+
96121
<!-- broadcast receivers -->
97122
<receiver
98123
android:name="fr.acinq.phoenix.android.services.BootReceiver"

phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/MainActivity.kt

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,26 @@ import android.content.BroadcastReceiver
2020
import android.content.Context
2121
import android.content.Intent
2222
import android.content.IntentFilter
23+
import android.content.pm.PackageManager
24+
import android.nfc.NfcAdapter
2325
import android.os.Bundle
26+
import android.widget.Toast
2427
import androidx.activity.compose.setContent
2528
import androidx.activity.enableEdgeToEdge
2629
import androidx.activity.viewModels
2730
import androidx.appcompat.app.AppCompatActivity
2831
import androidx.compose.runtime.collectAsState
2932
import androidx.core.net.toUri
3033
import androidx.lifecycle.lifecycleScope
31-
import androidx.navigation.*
34+
import androidx.navigation.NavHostController
3235
import androidx.navigation.compose.rememberNavController
3336
import fr.acinq.lightning.utils.Connection
37+
import fr.acinq.phoenix.android.components.nfc.NfcState
38+
import fr.acinq.phoenix.android.components.nfc.NfcStateRepository
39+
import fr.acinq.phoenix.android.services.HceService
3440
import fr.acinq.phoenix.android.services.NodeService
3541
import fr.acinq.phoenix.android.utils.PhoenixAndroidTheme
42+
import fr.acinq.phoenix.android.utils.nfc.NfcReaderCallback
3643
import fr.acinq.phoenix.managers.AppConnectionsDaemon
3744
import kotlinx.coroutines.launch
3845
import org.slf4j.Logger
@@ -55,6 +62,8 @@ class MainActivity : AppCompatActivity() {
5562
}
5663
}
5764

65+
private var nfcAdapter: NfcAdapter? = null
66+
5867
override fun onCreate(savedInstanceState: Bundle?) {
5968
enableEdgeToEdge()
6069
super.onCreate(savedInstanceState)
@@ -63,6 +72,9 @@ class MainActivity : AppCompatActivity() {
6372
}
6473

6574
intent?.fixUri()
75+
onNewIntent(intent)
76+
77+
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
6678

6779
// lock screen when screen is off
6880
val intentFilter = IntentFilter(Intent.ACTION_SCREEN_ON)
@@ -91,17 +103,24 @@ class MainActivity : AppCompatActivity() {
91103

92104
override fun onNewIntent(intent: Intent?) {
93105
super.onNewIntent(intent)
94-
// force the intent flag to single top, in order to avoid [handleDeepLink] finish the current activity.
95-
// this would otherwise clear the app view model, i.e. loose the state which virtually reboots the app
96-
// TODO: look into detaching the app state from the activity
97-
log.info("receive new_intent with data=${intent?.data}")
98-
intent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
106+
log.info("receive new_intent with action=${intent?.action} data=${intent?.data}")
99107

100-
intent?.fixUri()
101-
try {
102-
this.navController?.handleDeepLink(intent)
103-
} catch (e: Exception) {
104-
log.warn("could not handle deeplink: {}", e.localizedMessage)
108+
when (intent?.action) {
109+
NfcAdapter.ACTION_NDEF_DISCOVERED, NfcAdapter.ACTION_TAG_DISCOVERED -> {
110+
// ignored
111+
}
112+
else -> {
113+
// force the intent flag to single top, in order to avoid [handleDeepLink] finish the current activity.
114+
// this would otherwise clear the app view model, i.e. loose the state which virtually reboots the app
115+
// TODO: look into detaching the app state from the activity
116+
intent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
117+
intent?.fixUri()
118+
try {
119+
this.navController?.handleDeepLink(intent)
120+
} catch (e: Exception) {
121+
log.warn("could not handle deeplink: {}", e.localizedMessage)
122+
}
123+
}
105124
}
106125
}
107126

@@ -117,6 +136,71 @@ class MainActivity : AppCompatActivity() {
117136
tryReconnect()
118137
}
119138

139+
override fun onPause() {
140+
super.onPause()
141+
stopNfcReader()
142+
}
143+
144+
fun isNfcReaderAvailable() : Boolean {
145+
return nfcAdapter?.isEnabled == true
146+
}
147+
148+
fun startNfcReader() {
149+
if (NfcStateRepository.isEmulating()) {
150+
Toast.makeText(applicationContext, applicationContext.getString(R.string.nfc_err_busy), Toast.LENGTH_SHORT).show()
151+
return
152+
}
153+
NfcStateRepository.updateState(NfcState.ShowReader)
154+
nfcAdapter?.enableReaderMode(this@MainActivity, NfcReaderCallback(onFoundData = {
155+
runOnUiThread {
156+
log.info("nfc reader found valid ndef data, redirecting to send-screen with input=$it")
157+
this.navController?.navigate("${Screen.Send.route}?input=$it")
158+
}
159+
}), NfcAdapter.FLAG_READER_NFC_A or NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, Bundle())
160+
}
161+
162+
fun stopNfcReader() {
163+
if (NfcStateRepository.isReading()) {
164+
NfcStateRepository.updateState(NfcState.Inactive)
165+
}
166+
nfcAdapter?.disableReaderMode(this@MainActivity)
167+
}
168+
169+
fun isHceSupported() : Boolean {
170+
val adapter = nfcAdapter
171+
return adapter != null && adapter.isEnabled && packageManager.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION)
172+
}
173+
174+
fun startHceService(paymentRequest: String) {
175+
if (nfcAdapter == null) {
176+
Toast.makeText(this, applicationContext.getString(R.string.nfc_err_not_available), Toast.LENGTH_SHORT).show()
177+
return
178+
}
179+
180+
if (nfcAdapter?.isEnabled == false) {
181+
Toast.makeText(this, applicationContext.getString(R.string.nfc_err_disabled), Toast.LENGTH_SHORT).show()
182+
return
183+
}
184+
185+
if (!packageManager.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION)) {
186+
Toast.makeText(this, applicationContext.getString(R.string.nfc_err_hce_not_supported), Toast.LENGTH_SHORT).show()
187+
return
188+
}
189+
190+
if (NfcStateRepository.isReading()) {
191+
Toast.makeText(applicationContext, applicationContext.getString(R.string.nfc_err_busy), Toast.LENGTH_SHORT).show()
192+
return
193+
}
194+
195+
NfcStateRepository.updateState(NfcState.EmulatingTag(paymentRequest))
196+
val intent = Intent(this@MainActivity, HceService::class.java)
197+
startService(intent)
198+
}
199+
200+
fun stopHceService() {
201+
stopService(Intent(this@MainActivity, HceService::class.java))
202+
}
203+
120204
private fun tryReconnect() {
121205
lifecycleScope.launch {
122206
(application as? PhoenixApplication)?.business?.value?.let { business ->

phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/components/dialogs/BottomSheetDialog.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import androidx.compose.material.MaterialTheme
2929
import androidx.compose.material3.BottomSheetDefaults
3030
import androidx.compose.material3.ExperimentalMaterial3Api
3131
import androidx.compose.material3.ModalBottomSheet
32+
import androidx.compose.material3.ModalBottomSheetProperties
3233
import androidx.compose.material3.rememberModalBottomSheetState
3334
import androidx.compose.runtime.Composable
3435
import androidx.compose.ui.Alignment
@@ -53,9 +54,11 @@ fun ModalBottomSheet(
5354
contentHeight: Dp = Dp.Unspecified,
5455
internalPadding: PaddingValues = PaddingValues(top = 0.dp, start = 20.dp, end = 20.dp, bottom = 64.dp),
5556
isContentScrollable: Boolean = true,
57+
dismissOnScrimClick: Boolean = true,
58+
dismissOnBack: Boolean = true,
5659
content: @Composable ColumnScope.() -> Unit,
5760
) {
58-
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded)
61+
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded, confirmValueChange = { dismissOnScrimClick })
5962
ModalBottomSheet(
6063
sheetState = sheetState,
6164
onDismissRequest = {
@@ -69,6 +72,7 @@ fun ModalBottomSheet(
6972
dragHandle = dragHandle,
7073
contentWindowInsets = contentWindowInsets,
7174
scrimColor = MaterialTheme.colors.onBackground.copy(alpha = scrimAlpha),
75+
properties = ModalBottomSheetProperties(shouldDismissOnBackPress = dismissOnBack)
7276
) {
7377
Column(
7478
horizontalAlignment = horizontalAlignment,
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
* Copyright 2025 ACINQ SAS
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package fr.acinq.phoenix.android.components.nfc
18+
19+
import androidx.compose.foundation.Image
20+
import androidx.compose.foundation.layout.PaddingValues
21+
import androidx.compose.foundation.layout.Spacer
22+
import androidx.compose.foundation.layout.fillMaxWidth
23+
import androidx.compose.foundation.layout.height
24+
import androidx.compose.foundation.layout.size
25+
import androidx.compose.material.MaterialTheme
26+
import androidx.compose.material.Text
27+
import androidx.compose.runtime.Composable
28+
import androidx.compose.runtime.collectAsState
29+
import androidx.compose.runtime.getValue
30+
import androidx.compose.ui.Alignment
31+
import androidx.compose.ui.Modifier
32+
import androidx.compose.ui.graphics.ColorFilter
33+
import androidx.compose.ui.platform.LocalContext
34+
import androidx.compose.ui.res.painterResource
35+
import androidx.compose.ui.res.stringResource
36+
import androidx.compose.ui.unit.dp
37+
import fr.acinq.phoenix.android.R
38+
import fr.acinq.phoenix.android.components.MutedFilledButton
39+
import fr.acinq.phoenix.android.components.dialogs.ModalBottomSheet
40+
import fr.acinq.phoenix.android.utils.extensions.findActivitySafe
41+
42+
43+
/** Displays a blocking bottom sheet when the HCE service is started, using the state in [NfcStateRepository]. */
44+
@Composable
45+
fun HceMonitorDialog() {
46+
val context = LocalContext.current
47+
val activity = context.findActivitySafe() ?: return
48+
49+
val state by NfcStateRepository.state.collectAsState(initial = null)
50+
51+
val onDone = { activity.stopHceService() }
52+
53+
when (state) {
54+
is NfcState.EmulatingTag -> ModalBottomSheet(
55+
onDismiss = onDone,
56+
scrimAlpha = .5f,
57+
horizontalAlignment = Alignment.CenterHorizontally,
58+
internalPadding = PaddingValues(horizontal = 24.dp),
59+
dismissOnScrimClick = false,
60+
) {
61+
Text(text = stringResource(R.string.nfc_hce_emulation_title), style = MaterialTheme.typography.h4)
62+
Spacer(Modifier.height(16.dp))
63+
Image(
64+
painter = painterResource(id = R.drawable.ic_nfc),
65+
contentDescription = stringResource(R.string.nfc_button),
66+
modifier = Modifier.size(64.dp),
67+
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary)
68+
)
69+
Spacer(Modifier.height(24.dp))
70+
MutedFilledButton(
71+
text = stringResource(R.string.btn_cancel),
72+
icon = R.drawable.ic_cross,
73+
onClick = onDone,
74+
modifier = Modifier.fillMaxWidth()
75+
)
76+
Spacer(Modifier.height(24.dp))
77+
}
78+
is NfcState.ShowReader -> {}
79+
is NfcState.Inactive -> {}
80+
null -> {}
81+
}
82+
}

0 commit comments

Comments
 (0)