Skip to content

Commit 1923664

Browse files
committed
feat: Pin when idle
1 parent 32d6349 commit 1923664

File tree

7 files changed

+150
-15
lines changed

7 files changed

+150
-15
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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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, isAuthenticated=false.")
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()
70+
else -> Unit
71+
}
72+
}
73+
lifecycleOwner.lifecycle.addObserver(observer)
74+
onDispose {
75+
lifecycleOwner.lifecycle.removeObserver(observer)
76+
inactivityJob?.cancel()
77+
}
78+
}
79+
80+
Box(
81+
modifier = modifier.let { baseModifier ->
82+
if (isPinOnIdleEnabled) {
83+
baseModifier.pointerInput(Unit) {
84+
while (true) {
85+
awaitPointerEventScope {
86+
awaitPointerEvent()
87+
resetInactivityTimeout()
88+
}
89+
}
90+
}
91+
} else {
92+
baseModifier
93+
}
94+
}
95+
) {
96+
content()
97+
}
98+
}

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/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

@@ -835,13 +844,13 @@ class AppViewModel @Inject constructor(
835844
fun removePin() {
836845
setIsPinEnabled(false)
837846
setIsPinOnLaunchEnabled(true)
847+
setIsPinOnIdleEnabled(false)
838848
setIsBiometricEnabled(false)
839849

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

0 commit comments

Comments
 (0)