Skip to content

Commit 05f4e74

Browse files
authored
Merge pull request #117 from synonymdev/feat/pin-when-idle
feat: PIN when idle
2 parents ca35f76 + 08650e8 commit 05f4e74

File tree

9 files changed

+159
-25
lines changed

9 files changed

+159
-25
lines changed

app/src/main/java/to/bitkit/data/SettingsStore.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ class SettingsStore @Inject constructor(
7777
val isBiometricEnabled: Flow<Boolean> = store.data.map { it[IS_BIOMETRIC_ENABLED] == true }
7878
suspend fun setIsBiometricEnabled(value: Boolean) { store.edit { it[IS_BIOMETRIC_ENABLED] = value } }
7979

80+
val isPinOnIdleEnabled: Flow<Boolean> = store.data.map { it[IS_PIN_ON_IDLE_ENABLED] == true }
81+
suspend fun setIsPinOnIdleEnabled(value: Boolean) { store.edit { it[IS_PIN_ON_IDLE_ENABLED] = value } }
82+
8083
suspend fun wipe() {
8184
store.edit { it.clear() }
8285
Logger.info("Deleted all user settings data.")
@@ -93,5 +96,6 @@ class SettingsStore @Inject constructor(
9396
private val IS_PIN_ENABLED = booleanPreferencesKey("is_pin_enabled")
9497
private val IS_PIN_ON_LAUNCH_ENABLED = booleanPreferencesKey("is_pin_on_launch_enabled")
9598
private val IS_BIOMETRIC_ENABLED = booleanPreferencesKey("is_biometric_enabled")
99+
private val IS_PIN_ON_IDLE_ENABLED = booleanPreferencesKey("is_pin_on_idle_enabled")
96100
}
97101
}

app/src/main/java/to/bitkit/data/keychain/Keychain.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ class Keychain @Inject constructor(
7171
} catch (_: Exception) {
7272
throw KeychainError.FailedToSave(key)
7373
}
74-
Logger.info("Saved/updated in keychain: $key")
74+
Logger.info("Upsert in keychain: $key")
7575
}
7676

7777
suspend fun delete(key: String) {

app/src/main/java/to/bitkit/ui/MainActivity.kt

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
2020
import kotlinx.serialization.Serializable
2121
import to.bitkit.ui.components.AuthCheckView
2222
import to.bitkit.ui.components.ForgotPinSheet
23+
import to.bitkit.ui.components.InactivityTracker
2324
import to.bitkit.ui.components.ToastOverlay
2425
import to.bitkit.ui.onboarding.CreateWalletWithPassphraseScreen
2526
import to.bitkit.ui.onboarding.IntroScreen
@@ -178,14 +179,16 @@ class MainActivity : FragmentActivity() {
178179
}
179180
}
180181
} else {
181-
ContentView(
182-
appViewModel = appViewModel,
183-
walletViewModel = walletViewModel,
184-
blocktankViewModel = blocktankViewModel,
185-
currencyViewModel = currencyViewModel,
186-
activityListViewModel = activityListViewModel,
187-
transferViewModel = transferViewModel,
188-
)
182+
InactivityTracker(appViewModel) {
183+
ContentView(
184+
appViewModel = appViewModel,
185+
walletViewModel = walletViewModel,
186+
blocktankViewModel = blocktankViewModel,
187+
currencyViewModel = currencyViewModel,
188+
activityListViewModel = activityListViewModel,
189+
transferViewModel = transferViewModel,
190+
)
191+
}
189192

190193
val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle()
191194
AnimatedVisibility(

app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ fun AuthCheckScreen(
1616

1717
val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle()
1818
val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle()
19+
val isPinOnIdleEnabled by app.isPinOnIdleEnabled.collectAsStateWithLifecycle()
1920

2021
AuthCheckView(
2122
showLogoOnPin = route.showLogoOnPin,
@@ -32,6 +33,10 @@ fun AuthCheckScreen(
3233
app.setIsPinOnLaunchEnabled(!isPinOnLaunchEnabled)
3334
}
3435

36+
AuthCheckAction.TOGGLE_PIN_ON_IDLE -> {
37+
app.setIsPinOnIdleEnabled(!isPinOnIdleEnabled)
38+
}
39+
3540
AuthCheckAction.DISABLE_PIN -> {
3641
app.removePin()
3742
}
@@ -44,7 +49,8 @@ fun AuthCheckScreen(
4449
}
4550

4651
object AuthCheckAction {
47-
const val TOGGLE_PIN_ON_LAUNCH = "toggle_pin_on_launch"
48-
const val TOGGLE_BIOMETRICS = "toggle_biometrics"
49-
const val DISABLE_PIN = "disable_pin"
52+
const val TOGGLE_PIN_ON_LAUNCH = "TOGGLE_PIN_ON_LAUNCH"
53+
const val TOGGLE_BIOMETRICS = "TOGGLE_BIOMETRICS"
54+
const val TOGGLE_PIN_ON_IDLE = "TOGGLE_PIN_ON_IDLE"
55+
const val DISABLE_PIN = "DISABLE_PIN"
5056
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package to.bitkit.ui.components
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.runtime.DisposableEffect
6+
import androidx.compose.runtime.LaunchedEffect
7+
import androidx.compose.runtime.getValue
8+
import androidx.compose.runtime.mutableStateOf
9+
import androidx.compose.runtime.remember
10+
import androidx.compose.runtime.rememberCoroutineScope
11+
import androidx.compose.runtime.setValue
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.input.pointer.pointerInput
14+
import androidx.lifecycle.Lifecycle
15+
import androidx.lifecycle.LifecycleEventObserver
16+
import androidx.lifecycle.compose.LocalLifecycleOwner
17+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
18+
import kotlinx.coroutines.Job
19+
import kotlinx.coroutines.delay
20+
import kotlinx.coroutines.launch
21+
import to.bitkit.utils.Logger
22+
import to.bitkit.viewmodels.AppViewModel
23+
24+
private const val INACTIVITY_DELAY = 90_000L // 90 seconds
25+
26+
@Composable
27+
fun InactivityTracker(
28+
app: AppViewModel,
29+
modifier: Modifier = Modifier,
30+
content: @Composable () -> Unit,
31+
) {
32+
val scope = rememberCoroutineScope()
33+
val lifecycleOwner = LocalLifecycleOwner.current
34+
35+
val isPinEnabled by app.isPinEnabled.collectAsStateWithLifecycle()
36+
val isPinOnIdleEnabled by app.isPinOnIdleEnabled.collectAsStateWithLifecycle()
37+
val isAuthenticated by app.isAuthenticated.collectAsStateWithLifecycle()
38+
39+
var inactivityJob by remember { mutableStateOf<Job?>(null) }
40+
41+
fun resetInactivityTimeout() {
42+
inactivityJob?.cancel()?.also {
43+
inactivityJob = null
44+
}
45+
if (isPinEnabled && isPinOnIdleEnabled && isAuthenticated) {
46+
inactivityJob = scope.launch {
47+
delay(INACTIVITY_DELAY)
48+
Logger.debug("Inactivity timeout reached after ${INACTIVITY_DELAY / 1000}s, resetting isAuthenticated.")
49+
app.setIsAuthenticated(false)
50+
resetInactivityTimeout()
51+
}
52+
}
53+
}
54+
55+
LaunchedEffect(isAuthenticated, isPinEnabled, isPinOnIdleEnabled) {
56+
if (isAuthenticated) {
57+
resetInactivityTimeout()
58+
} else {
59+
inactivityJob?.cancel()?.also {
60+
inactivityJob = null
61+
}
62+
}
63+
}
64+
65+
DisposableEffect(lifecycleOwner) {
66+
val observer = LifecycleEventObserver { _, event ->
67+
when (event) {
68+
Lifecycle.Event.ON_RESUME -> resetInactivityTimeout()
69+
Lifecycle.Event.ON_PAUSE -> inactivityJob?.cancel()?.also { inactivityJob = null }
70+
else -> Unit
71+
}
72+
}
73+
lifecycleOwner.lifecycle.addObserver(observer)
74+
onDispose {
75+
lifecycleOwner.lifecycle.removeObserver(observer)
76+
inactivityJob?.cancel()?.also {
77+
inactivityJob = null
78+
}
79+
}
80+
}
81+
82+
Box(
83+
modifier = modifier.let { baseModifier ->
84+
if (isPinOnIdleEnabled) {
85+
baseModifier.pointerInput(Unit) {
86+
while (true) {
87+
awaitPointerEventScope {
88+
awaitPointerEvent()
89+
resetInactivityTimeout()
90+
}
91+
}
92+
}
93+
} else {
94+
baseModifier
95+
}
96+
}
97+
) {
98+
content()
99+
}
100+
}

app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ fun SecuritySettingsScreen(
4343
val isPinEnabled by app.isPinEnabled.collectAsStateWithLifecycle()
4444
val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle()
4545
val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle()
46+
val isPinOnIdleEnabled by app.isPinOnIdleEnabled.collectAsStateWithLifecycle()
4647

4748
PinNavigationSheet(
4849
showSheet = showPinSheet,
@@ -53,6 +54,7 @@ fun SecuritySettingsScreen(
5354
isPinEnabled = isPinEnabled,
5455
isPinOnLaunchEnabled = isPinOnLaunchEnabled,
5556
isBiometricEnabled = isBiometricEnabled,
57+
isPinOnIdleEnabled = isPinOnIdleEnabled,
5658
isBiometrySupported = rememberBiometricAuthSupported(),
5759
onPinClick = {
5860
if (!isPinEnabled) {
@@ -69,6 +71,11 @@ fun SecuritySettingsScreen(
6971
onSuccessActionId = AuthCheckAction.TOGGLE_PIN_ON_LAUNCH,
7072
)
7173
},
74+
onPinOnIdleClick = {
75+
navController.navigateToAuthCheck(
76+
onSuccessActionId = AuthCheckAction.TOGGLE_PIN_ON_IDLE,
77+
)
78+
},
7279
onUseBiometricsClick = {
7380
navController.navigateToAuthCheck(
7481
requireBiometrics = true,
@@ -86,10 +93,12 @@ private fun SecuritySettingsContent(
8693
isPinEnabled: Boolean,
8794
isPinOnLaunchEnabled: Boolean,
8895
isBiometricEnabled: Boolean,
96+
isPinOnIdleEnabled: Boolean,
8997
isBiometrySupported: Boolean,
9098
onPinClick: () -> Unit = {},
9199
onChangePinClick: () -> Unit = {},
92100
onPinOnLaunchClick: () -> Unit = {},
101+
onPinOnIdleClick: () -> Unit = {},
93102
onUseBiometricsClick: () -> Unit = {},
94103
onBackClick: () -> Unit = {},
95104
onCloseClick: () -> Unit = {},
@@ -122,10 +131,15 @@ private fun SecuritySettingsContent(
122131
isChecked = isPinOnLaunchEnabled,
123132
onClick = onPinOnLaunchClick,
124133
)
134+
SettingsSwitchRow(
135+
title = stringResource(R.string.settings__security__pin_idle),
136+
isChecked = isPinOnIdleEnabled,
137+
onClick = onPinOnIdleClick,
138+
)
125139
}
126140
if (isPinEnabled && isBiometrySupported) {
127141
SettingsSwitchRow(
128-
title = let {
142+
title = run {
129143
val bioTypeName = stringResource(R.string.security__bio)
130144
stringResource(R.string.settings__security__use_bio).replace("{biometryTypeName}", bioTypeName)
131145
},
@@ -135,7 +149,7 @@ private fun SecuritySettingsContent(
135149
}
136150
if (isPinEnabled && isBiometrySupported) {
137151
BodyS(
138-
text = let {
152+
text = run {
139153
val bioTypeName = stringResource(R.string.security__bio)
140154
stringResource(R.string.settings__security__footer).replace("{biometryTypeName}", bioTypeName)
141155
},
@@ -155,6 +169,7 @@ fun Preview() {
155169
isPinEnabled = true,
156170
isPinOnLaunchEnabled = true,
157171
isBiometricEnabled = false,
172+
isPinOnIdleEnabled = false,
158173
isBiometrySupported = true,
159174
)
160175
}

app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import androidx.navigation.compose.composable
1010
import androidx.navigation.compose.rememberNavController
1111
import androidx.navigation.toRoute
1212
import kotlinx.serialization.Serializable
13-
import to.bitkit.ui.appViewModel
1413
import to.bitkit.ui.components.SheetHost
1514

1615
@Composable
@@ -20,7 +19,6 @@ fun PinNavigationSheet(
2019
onDismiss: () -> Unit = {},
2120
content: @Composable () -> Unit,
2221
) {
23-
val app = appViewModel ?: return
2422
val navController = rememberNavController()
2523

2624
SheetHost(
@@ -88,20 +86,19 @@ fun PinNavigationSheet(
8886
)
8987
}
9088

91-
@Serializable
92-
sealed class PinRoute {
89+
object PinRoute {
9390
@Serializable
94-
data object PinPrompt : PinRoute()
91+
data object PinPrompt
9592

9693
@Serializable
97-
data object ChoosePin : PinRoute()
94+
data object ChoosePin
9895

9996
@Serializable
100-
data class ConfirmPin(val pin: String) : PinRoute()
97+
data class ConfirmPin(val pin: String)
10198

10299
@Serializable
103-
data object AskForBiometrics : PinRoute()
100+
data object AskForBiometrics
104101

105102
@Serializable
106-
data class Result(val isBioOn: Boolean) : PinRoute()
103+
data class Result(val isBioOn: Boolean)
107104
}

app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ class AppViewModel @Inject constructor(
126126
}
127127
}
128128

129+
val isPinOnIdleEnabled: StateFlow<Boolean> = settingsStore.isPinOnIdleEnabled
130+
.stateIn(viewModelScope, SharingStarted.Lazily, false)
131+
132+
fun setIsPinOnIdleEnabled(value: Boolean) {
133+
viewModelScope.launch {
134+
settingsStore.setIsPinOnIdleEnabled(value)
135+
}
136+
}
137+
129138
val isBiometricEnabled: StateFlow<Boolean> = settingsStore.isBiometricEnabled
130139
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
131140

@@ -834,13 +843,13 @@ class AppViewModel @Inject constructor(
834843
fun removePin() {
835844
setIsPinEnabled(false)
836845
setIsPinOnLaunchEnabled(true)
846+
setIsPinOnIdleEnabled(false)
837847
setIsBiometricEnabled(false)
838848

839849
viewModelScope.launch {
840850
keychain.delete(Keychain.Key.PIN.name)
841851
keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, Env.PIN_ATTEMPTS.toString())
842852
}
843-
844853
}
845854
// endregion
846855
}

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k
8181
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
8282
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
8383
ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version.ref = "ldkNode" }
84-
lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version = "lifecycle" }
84+
lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" }
8585
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
8686
lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
8787
lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }

0 commit comments

Comments
 (0)