Skip to content

Commit 3b878c9

Browse files
dpad85robbiehanson
andauthored
Add PIN code for spending (#711)
Spending operations (including swap refund or channel closing) can be protected behind a 6-digits PIN different from the screen lock authentication. This allows sharing the wallet with someone else. --------- Co-authored-by: Robbie Hanson <[email protected]>
1 parent 4f95714 commit 3b878c9

File tree

68 files changed

+2892
-1253
lines changed

Some content is hidden

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

68 files changed

+2892
-1253
lines changed

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/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+
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2024 ACINQ SAS
2+
* Copyright 2025 ACINQ SAS
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -14,68 +14,65 @@
1414
* limitations under the License.
1515
*/
1616

17-
package fr.acinq.phoenix.android.components.screenlock
17+
package fr.acinq.phoenix.android.components.auth.pincode
1818

1919
import android.content.Context
2020
import androidx.compose.runtime.getValue
2121
import androidx.compose.runtime.mutableStateOf
2222
import androidx.compose.runtime.setValue
2323
import androidx.lifecycle.ViewModel
24-
import androidx.lifecycle.ViewModelProvider
2524
import androidx.lifecycle.viewModelScope
26-
import androidx.lifecycle.viewmodel.CreationExtras
27-
import fr.acinq.phoenix.android.PhoenixApplication
28-
import fr.acinq.phoenix.android.components.screenlock.PinDialog.PIN_LENGTH
29-
import fr.acinq.phoenix.android.security.EncryptedPin
30-
import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository
25+
import fr.acinq.phoenix.android.components.auth.pincode.PinDialog.PIN_LENGTH
3126
import kotlinx.coroutines.Dispatchers
3227
import kotlinx.coroutines.cancelAndJoin
3328
import kotlinx.coroutines.delay
34-
import kotlinx.coroutines.flow.first
3529
import kotlinx.coroutines.flow.flow
3630
import kotlinx.coroutines.launch
31+
import org.slf4j.Logger
3732
import org.slf4j.LoggerFactory
3833
import kotlin.time.Duration
3934
import kotlin.time.Duration.Companion.minutes
4035
import kotlin.time.Duration.Companion.seconds
4136

42-
/** View model tracking the state of the PIN dialog UI. */
43-
sealed class CheckPinFlowState {
44-
45-
data object Init : CheckPinFlowState()
4637

47-
data class Locked(val timeToWait: Duration): CheckPinFlowState()
48-
data object CanType : CheckPinFlowState()
49-
data object Checking : CheckPinFlowState()
50-
data object MalformedInput: CheckPinFlowState()
51-
data object IncorrectPin: CheckPinFlowState()
52-
data class Error(val cause: Throwable) : CheckPinFlowState()
38+
/** View model tracking the state of the PIN dialog UI. */
39+
sealed class CheckPinState {
40+
data object Init : CheckPinState()
41+
data class Locked(val timeToWait: Duration): CheckPinState()
42+
data object CanType : CheckPinState()
43+
data object Checking : CheckPinState()
44+
data object MalformedInput: CheckPinState()
45+
data object IncorrectPin: CheckPinState()
46+
data class Error(val cause: Throwable) : CheckPinState()
5347
}
5448

55-
class CheckPinFlowViewModel(private val userPrefsRepository: UserPrefsRepository) : ViewModel() {
56-
private val log = LoggerFactory.getLogger(this::class.java)
57-
var state by mutableStateOf<CheckPinFlowState>(CheckPinFlowState.Init)
49+
abstract class CheckPinViewModel : ViewModel() {
50+
val log: Logger = LoggerFactory.getLogger(this::class.java)
51+
var state by mutableStateOf<CheckPinState>(CheckPinState.Init)
5852
private set
5953

6054
var pinInput by mutableStateOf("")
6155

62-
init {
63-
viewModelScope.launch { evaluateLockState() }
64-
}
56+
abstract suspend fun getPinCodeAttempt(): Int
57+
abstract suspend fun savePinCodeSuccess()
58+
abstract suspend fun savePinCodeFailure()
59+
abstract suspend fun getExpectedPin(context: Context): String?
6560

66-
private suspend fun evaluateLockState() {
67-
val currentPinCodeAttempt = userPrefsRepository.getPinCodeAttempt.first()
61+
suspend fun evaluateLockState() {
62+
val currentPinCodeAttempt = getPinCodeAttempt()
6863
val timeToWait = when (currentPinCodeAttempt) {
6964
0, 1, 2 -> Duration.ZERO
7065
3 -> 10.seconds
71-
4 -> 1.minutes
72-
5 -> 2.minutes
73-
6 -> 5.minutes
74-
7 -> 10.minutes
66+
4 -> 30.seconds
67+
5 -> 1.minutes
68+
6 -> 2.minutes
69+
7 -> 5.minutes
70+
8 -> 10.minutes
7571
else -> 30.minutes
7672
}
73+
7774
if (timeToWait > Duration.ZERO) {
78-
state = CheckPinFlowState.Locked(timeToWait)
75+
state = CheckPinState.Locked(timeToWait)
7976
val countdownJob = viewModelScope.launch {
8077
val countdownFlow = flow {
8178
while (true) {
@@ -85,70 +82,60 @@ class CheckPinFlowViewModel(private val userPrefsRepository: UserPrefsRepository
8582
}
8683
countdownFlow.collect {
8784
val s = state
88-
if (s is CheckPinFlowState.Locked) {
89-
state = CheckPinFlowState.Locked((s.timeToWait.minus(1.seconds)).coerceAtLeast(Duration.ZERO))
85+
if (s is CheckPinState.Locked) {
86+
state = CheckPinState.Locked((s.timeToWait.minus(1.seconds)).coerceAtLeast(Duration.ZERO))
9087
}
9188
}
9289
}
9390
delay(timeToWait)
9491
countdownJob.cancelAndJoin()
95-
state = CheckPinFlowState.CanType
92+
state = CheckPinState.CanType
9693
} else {
97-
state = CheckPinFlowState.CanType
94+
state = CheckPinState.CanType
9895
}
9996
}
10097

10198
fun checkPinAndSaveOutcome(context: Context, pin: String, onPinValid: () -> Unit) {
102-
if (state is CheckPinFlowState.Checking || state is CheckPinFlowState.Locked) return
103-
state = CheckPinFlowState.Checking
99+
if (state is CheckPinState.Checking || state is CheckPinState.Locked) return
100+
state = CheckPinState.Checking
104101

105102
viewModelScope.launch(Dispatchers.IO) {
106103
try {
107104
if (pin.isBlank() || pin.length != PIN_LENGTH) {
108105
log.debug("malformed pin")
109-
state = CheckPinFlowState.MalformedInput
106+
state = CheckPinState.MalformedInput
110107
delay(1300)
111-
if (state is CheckPinFlowState.MalformedInput) {
108+
if (state is CheckPinState.MalformedInput) {
112109
evaluateLockState()
113110
}
114111
}
115112

116-
val expected = EncryptedPin.getPinFromDisk(context)
113+
val expected = getExpectedPin(context)
117114
if (pin == expected) {
118115
log.debug("valid pin")
119116
delay(20)
120-
userPrefsRepository.savePinCodeSuccess()
117+
savePinCodeSuccess()
121118
pinInput = ""
122-
state = CheckPinFlowState.CanType
119+
state = CheckPinState.CanType
123120
viewModelScope.launch(Dispatchers.Main) {
124121
onPinValid()
125122
}
126123
} else {
127124
log.debug("incorrect pin")
128125
delay(80)
129-
userPrefsRepository.savePinCodeFailure()
130-
state = CheckPinFlowState.IncorrectPin
126+
savePinCodeFailure()
127+
state = CheckPinState.IncorrectPin
131128
delay(1300)
132129
pinInput = ""
133130
evaluateLockState()
134131
}
135132
} catch (e: Exception) {
136133
log.error("error when checking pin code: ", e)
137-
state = CheckPinFlowState.Error(e)
134+
state = CheckPinState.Error(e)
138135
delay(1300)
139136
pinInput = ""
140137
evaluateLockState()
141138
}
142139
}
143140
}
144-
145-
companion object {
146-
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
147-
@Suppress("UNCHECKED_CAST")
148-
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
149-
val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication)
150-
return CheckPinFlowViewModel(application.userPrefs) as T
151-
}
152-
}
153-
}
154141
}

0 commit comments

Comments
 (0)