Skip to content

Commit

Permalink
(android) Add custom PIN option (#614)
Browse files Browse the repository at this point in the history
A 6-digits PIN code can now be defined to control access to
the application, in addition to the System screen lock option.
Both can be used at the same time, or just one.

This PIN code is specific to Phoenix and can be different from
the System PIN.

This change is similar to the PIN code update for iOS (#560).
  • Loading branch information
dpad85 authored Aug 23, 2024
1 parent 8afb6f4 commit 9bce2ac
Show file tree
Hide file tree
Showing 30 changed files with 1,915 additions and 566 deletions.
617 changes: 321 additions & 296 deletions phoenix-android/src/main/kotlin/fr/acinq/phoenix/android/AppView.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,29 @@ package fr.acinq.phoenix.android

import android.content.ComponentName
import android.content.ServiceConnection
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import fr.acinq.phoenix.android.services.NodeService
import fr.acinq.phoenix.android.services.NodeServiceState
import fr.acinq.phoenix.android.utils.datastore.InternalDataRepository
import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory

class AppViewModel(
private val internalDataRepository: InternalDataRepository
private val internalData: InternalDataRepository,
private val userPrefs: UserPrefsRepository,
) : ViewModel() {
val log = LoggerFactory.getLogger(AppViewModel::class.java)

Expand Down Expand Up @@ -62,8 +69,38 @@ class AppViewModel(

val isScreenLocked = mutableStateOf(true)

fun saveIsScreenLocked(isLocked: Boolean) {
isScreenLocked.value = isLocked
private val autoLockHandler = Handler(Looper.getMainLooper())
private val autoLockRunnable: Runnable = Runnable { lockScreen() }

init {
monitorUserLockPrefs()
scheduleAutoLock()
}

fun scheduleAutoLock() {
autoLockHandler.removeCallbacksAndMessages(null)
autoLockHandler.postDelayed(autoLockRunnable, 10 * 60 * 1000L)
}

private fun monitorUserLockPrefs() {
viewModelScope.launch {
combine(userPrefs.getIsBiometricLockEnabled, userPrefs.getIsCustomPinLockEnabled) { isBiometricEnabled, isCustomPinEnabled ->
isBiometricEnabled to isCustomPinEnabled
}.collect { (isBiometricEnabled, isCustomPinEnabled) ->
if (!isBiometricEnabled && !isCustomPinEnabled) {
unlockScreen()
}
}
}
}

fun unlockScreen() {
isScreenLocked.value = false
scheduleAutoLock()
}

fun lockScreen() {
isScreenLocked.value = true
}

override fun onCleared() {
Expand All @@ -76,12 +113,17 @@ class AppViewModel(
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val application = checkNotNull(extras[APPLICATION_KEY] as? PhoenixApplication)
return AppViewModel(application.internalDataRepository) as T
return AppViewModel(application.internalDataRepository, application.userPrefs) as T
}
}
}
}

sealed class LockState {
data object SettingUp: LockState()

}

class ServiceStateLiveData(service: MutableLiveData<NodeService?>) : MediatorLiveData<NodeServiceState>() {
private val log = LoggerFactory.getLogger(this::class.java)
private var serviceState: LiveData<NodeServiceState>? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@

package fr.acinq.phoenix.android

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.viewModels
Expand Down Expand Up @@ -52,6 +54,15 @@ class MainActivity : AppCompatActivity() {

private var navController: NavHostController? = null

private val screenStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> appViewModel.lockScreen()
else -> Unit
}
}
}

@OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand Down Expand Up @@ -97,6 +108,11 @@ class MainActivity : AppCompatActivity() {
}
}

// lock screen when screen is off
val intentFilter = IntentFilter(Intent.ACTION_SCREEN_ON)
intentFilter.addAction(Intent.ACTION_SCREEN_OFF)
registerReceiver(screenStateReceiver, intentFilter)

setContent {
navController = rememberNavController()
val businessState = (application as PhoenixApplication).business.collectAsState()
Expand All @@ -112,6 +128,11 @@ class MainActivity : AppCompatActivity() {
}
}

override fun onUserInteraction() {
super.onUserInteraction()
appViewModel.scheduleAutoLock()
}

override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
// force the intent flag to single top, in order to avoid [handleDeepLink] finish the current activity.
Expand All @@ -121,7 +142,11 @@ class MainActivity : AppCompatActivity() {
intent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP

intent?.fixUri()
this.navController?.handleDeepLink(intent)
try {
this.navController?.handleDeepLink(intent)
} catch (e: Exception) {
log.warn("could not handle deeplink: {}", e.localizedMessage)
}
}

override fun onStart() {
Expand Down Expand Up @@ -169,6 +194,7 @@ class MainActivity : AppCompatActivity() {
} catch (e: Exception) {
log.error("failed to unbind activity from node service: {}", e.localizedMessage)
}
unregisterReceiver(screenStateReceiver)
log.debug("destroyed main activity")
}

Expand All @@ -186,7 +212,7 @@ class MainActivity : AppCompatActivity() {
val ssp = initialUri?.schemeSpecificPart
if (scheme == "phoenix" && ssp != null && (ssp.startsWith("lnbc") || ssp.startsWith("lntb"))) {
this.data = "lightning:$ssp".toUri()
log.debug("rewritten intent uri from $initialUri to ${intent.data}")
log.debug("rewritten intent uri from {} to {}", initialUri, intent.data)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.phoenix.android.components.screenlock

import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import fr.acinq.phoenix.android.R

@Composable
fun CheckPinFlow(
onCancel: () -> Unit,
onPinValid: () -> Unit,
) {
val context = LocalContext.current
val vm = viewModel<CheckPinFlowViewModel>(factory = CheckPinFlowViewModel.Factory)

val isUIFrozen = vm.state !is CheckPinFlowState.CanType

BasePinDialog(
onDismiss = {
onCancel()
},
initialPin = vm.pinInput,
onPinSubmit = {
vm.pinInput = it
vm.checkPinAndSaveOutcome(context, it, onPinValid)
},
stateLabel = {
when(val state = vm.state) {
is CheckPinFlowState.Init, is CheckPinFlowState.CanType -> {
PinDialogTitle(text = stringResource(id = R.string.pincode_check_title))
}
is CheckPinFlowState.Locked -> {
PinDialogTitle(text = stringResource(id = R.string.pincode_locked_label, state.timeToWait.toString()), icon = R.drawable.ic_clock)
}
is CheckPinFlowState.Checking -> {
PinDialogTitle(text = stringResource(id = R.string.pincode_checking_label))
}
is CheckPinFlowState.MalformedInput -> {
PinDialogError(text = stringResource(id = R.string.pincode_error_malformed))
}
is CheckPinFlowState.IncorrectPin -> {
PinDialogError(text = stringResource(id = R.string.pincode_failure_label))
}
is CheckPinFlowState.Error -> {
PinDialogError(text = stringResource(id = R.string.pincode_error_generic))
}
}
},
enabled = !isUIFrozen,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright 2024 ACINQ SAS
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fr.acinq.phoenix.android.components.screenlock

import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import fr.acinq.phoenix.android.PhoenixApplication
import fr.acinq.phoenix.android.components.screenlock.PinDialog.PIN_LENGTH
import fr.acinq.phoenix.android.security.EncryptedPin
import fr.acinq.phoenix.android.utils.datastore.UserPrefsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import org.slf4j.LoggerFactory
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

/** View model tracking the state of the PIN dialog UI. */
sealed class CheckPinFlowState {

data object Init : CheckPinFlowState()

data class Locked(val timeToWait: Duration): CheckPinFlowState()
data object CanType : CheckPinFlowState()
data object Checking : CheckPinFlowState()
data object MalformedInput: CheckPinFlowState()
data object IncorrectPin: CheckPinFlowState()
data class Error(val cause: Throwable) : CheckPinFlowState()
}

class CheckPinFlowViewModel(private val userPrefsRepository: UserPrefsRepository) : ViewModel() {
private val log = LoggerFactory.getLogger(this::class.java)
var state by mutableStateOf<CheckPinFlowState>(CheckPinFlowState.Init)
private set

var pinInput by mutableStateOf("")

init {
viewModelScope.launch { evaluateLockState() }
}

private suspend fun evaluateLockState() {
val currentPinCodeAttempt = userPrefsRepository.getPinCodeAttempt.first()
val timeToWait = when (currentPinCodeAttempt) {
0, 1, 2 -> Duration.ZERO
3 -> 10.seconds
4 -> 1.minutes
5 -> 2.minutes
6 -> 5.minutes
7 -> 10.minutes
else -> 30.minutes
}
if (timeToWait > Duration.ZERO) {
state = CheckPinFlowState.Locked(timeToWait)
val countdownJob = viewModelScope.launch {
val countdownFlow = flow {
while (true) {
delay(1_000)
emit(Unit)
}
}
countdownFlow.collect {
val s = state
if (s is CheckPinFlowState.Locked) {
state = CheckPinFlowState.Locked((s.timeToWait.minus(1.seconds)).coerceAtLeast(Duration.ZERO))
}
}
}
delay(timeToWait)
countdownJob.cancelAndJoin()
state = CheckPinFlowState.CanType
} else {
state = CheckPinFlowState.CanType
}
}

fun checkPinAndSaveOutcome(context: Context, pin: String, onPinValid: () -> Unit) {
if (state is CheckPinFlowState.Checking || state is CheckPinFlowState.Locked) return
state = CheckPinFlowState.Checking

viewModelScope.launch(Dispatchers.IO) {
try {
if (pin.isBlank() || pin.length != PIN_LENGTH) {
log.debug("malformed pin")
state = CheckPinFlowState.MalformedInput
delay(1300)
if (state is CheckPinFlowState.MalformedInput) {
evaluateLockState()
}
}

val expected = EncryptedPin.getPinFromDisk(context)
if (pin == expected) {
log.debug("valid pin")
delay(100)
userPrefsRepository.savePinCodeSuccess()
pinInput = ""
state = CheckPinFlowState.CanType
viewModelScope.launch(Dispatchers.Main) {
onPinValid()
}
} else {
log.debug("incorrect pin")
delay(200)
userPrefsRepository.savePinCodeFailure()
state = CheckPinFlowState.IncorrectPin
delay(1300)
pinInput = ""
evaluateLockState()
}
} catch (e: Exception) {
log.error("error when checking pin code: ", e)
state = CheckPinFlowState.Error(e)
delay(1300)
pinInput = ""
evaluateLockState()
}
}
}

companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as? PhoenixApplication)
return CheckPinFlowViewModel(application.userPrefs) as T
}
}
}
}
Loading

0 comments on commit 9bce2ac

Please sign in to comment.