Skip to content

Commit 0696685

Browse files
committed
Merge branch 'master' into mainnet
2 parents 982aee3 + b26ab3a commit 0696685

File tree

100 files changed

+4307
-1421
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

100 files changed

+4307
-1421
lines changed

phoenix-android/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ android {
2626
applicationId = "fr.acinq.phoenix.mainnet"
2727
minSdk = 26
2828
targetSdk = 35
29-
versionCode = 104
30-
versionName = "2.6.0"
29+
versionCode = 105
30+
versionName = gitCommitHash()
3131
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3232
}
3333

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/AppView.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import fr.acinq.phoenix.PhoenixBusiness
7171
import fr.acinq.phoenix.android.components.Button
7272
import fr.acinq.phoenix.android.components.dialogs.Dialog
7373
import fr.acinq.phoenix.android.components.openLink
74-
import fr.acinq.phoenix.android.components.screenlock.LockPrompt
74+
import fr.acinq.phoenix.android.components.auth.screenlock.ScreenLockPrompt
7575
import fr.acinq.phoenix.android.home.HomeView
7676
import fr.acinq.phoenix.android.initwallet.create.CreateWalletView
7777
import fr.acinq.phoenix.android.initwallet.InitWallet
@@ -501,17 +501,17 @@ fun AppView(
501501
}
502502

503503
val isScreenLocked by appVM.isScreenLocked
504-
val isBiometricLockEnabledState = userPrefs.getIsBiometricLockEnabled.collectAsState(initial = null)
504+
val isBiometricLockEnabledState = userPrefs.getIsScreenLockBiometricsEnabled.collectAsState(initial = null)
505505
val isBiometricLockEnabled = isBiometricLockEnabledState.value
506-
val isCustomPinLockEnabledState = userPrefs.getIsCustomPinLockEnabled.collectAsState(initial = null)
506+
val isCustomPinLockEnabledState = userPrefs.getIsScreenLockPinEnabled.collectAsState(initial = null)
507507
val isCustomPinLockEnabled = isCustomPinLockEnabledState.value
508508

509509
if ((isBiometricLockEnabled == true || isCustomPinLockEnabled == true) && isScreenLocked) {
510510
BackHandler {
511511
// back button minimises the app
512512
context.findActivitySafe()?.moveTaskToBack(false)
513513
}
514-
LockPrompt(
514+
ScreenLockPrompt(
515515
promptScreenLockImmediately = appVM.promptScreenLockImmediately.value,
516516
onLock = { appVM.lockScreen() },
517517
onUnlock = {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class AppViewModel(
9292

9393
private fun monitorUserLockPrefs() {
9494
viewModelScope.launch {
95-
combine(userPrefs.getIsBiometricLockEnabled, userPrefs.getIsCustomPinLockEnabled) { isBiometricEnabled, isCustomPinEnabled ->
95+
combine(userPrefs.getIsScreenLockBiometricsEnabled, userPrefs.getIsScreenLockPinEnabled) { isBiometricEnabled, isCustomPinEnabled ->
9696
isBiometricEnabled to isCustomPinEnabled
9797
}.collect { (isBiometricEnabled, isCustomPinEnabled) ->
9898
if (!isBiometricEnabled && !isCustomPinEnabled) {

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/Checkbox.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ import androidx.compose.foundation.indication
2121
import androidx.compose.foundation.interaction.MutableInteractionSource
2222
import androidx.compose.foundation.layout.*
2323
import androidx.compose.material.Checkbox
24+
import androidx.compose.material.MaterialTheme
2425
import androidx.compose.material.Text
2526
import androidx.compose.material.ripple
26-
import androidx.compose.material.ripple.rememberRipple
2727
import androidx.compose.runtime.*
2828
import androidx.compose.runtime.saveable.rememberSaveable
2929
import androidx.compose.ui.Alignment
3030
import androidx.compose.ui.Modifier
3131
import androidx.compose.ui.semantics.Role
32+
import androidx.compose.ui.text.TextStyle
3233
import androidx.compose.ui.unit.dp
3334
import fr.acinq.phoenix.android.isDarkTheme
3435
import fr.acinq.phoenix.android.utils.gray300
@@ -40,6 +41,7 @@ fun Checkbox(
4041
checked: Boolean,
4142
onCheckedChange: (Boolean) -> Unit,
4243
modifier: Modifier = Modifier,
44+
textStyle: TextStyle = MaterialTheme.typography.body1,
4345
enabled: Boolean = true,
4446
padding: PaddingValues = PaddingValues(vertical = 16.dp, horizontal = 0.dp)
4547
) {
@@ -64,6 +66,6 @@ fun Checkbox(
6466
)
6567
)
6668
Spacer(Modifier.width(12.dp))
67-
Text(text)
69+
Text(text = text, style = textStyle)
6870
}
6971
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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.auth.pincode
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.runtime.LaunchedEffect
21+
import androidx.compose.ui.platform.LocalContext
22+
import androidx.compose.ui.res.stringResource
23+
import fr.acinq.phoenix.android.R
24+
25+
26+
@Composable
27+
fun CheckPinFlow(
28+
onCancel: () -> Unit,
29+
onPinValid: () -> Unit,
30+
vm: CheckPinViewModel,
31+
prompt: @Composable () -> Unit,
32+
) {
33+
val context = LocalContext.current
34+
val isUIFrozen = vm.state !is CheckPinState.CanType
35+
36+
LaunchedEffect(Unit) { vm.evaluateLockState() }
37+
38+
BasePinDialog(
39+
onDismiss = onCancel,
40+
initialPin = vm.pinInput,
41+
onPinSubmit = {
42+
vm.pinInput = it
43+
vm.checkPinAndSaveOutcome(context, it, onPinValid)
44+
},
45+
prompt = prompt,
46+
stateLabel = when(val state = vm.state) {
47+
is CheckPinState.Init, is CheckPinState.CanType -> null
48+
is CheckPinState.Locked -> {
49+
{ PinStateMessage(text = stringResource(id = R.string.pincode_locked_label, state.timeToWait.toString()), icon = R.drawable.ic_clock) }
50+
}
51+
is CheckPinState.Checking -> {
52+
{ PinStateMessage(text = stringResource(id = R.string.pincode_checking_label)) }
53+
}
54+
is CheckPinState.MalformedInput -> {
55+
{ PinStateError(text = stringResource(id = R.string.pincode_error_malformed)) }
56+
}
57+
is CheckPinState.IncorrectPin -> {
58+
{ PinStateError(text = stringResource(id = R.string.pincode_failure_label)) }
59+
}
60+
is CheckPinState.Error -> {
61+
{ PinStateError(text = stringResource(id = R.string.pincode_error_generic)) }
62+
}
63+
},
64+
enabled = !isUIFrozen,
65+
)
66+
}

0 commit comments

Comments
 (0)