diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index c2fcda99e..606471955 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -77,6 +77,9 @@ class SettingsStore @Inject constructor( val isBiometricEnabled: Flow = store.data.map { it[IS_BIOMETRIC_ENABLED] == true } suspend fun setIsBiometricEnabled(value: Boolean) { store.edit { it[IS_BIOMETRIC_ENABLED] = value } } + val isPinOnIdleEnabled: Flow = store.data.map { it[IS_PIN_ON_IDLE_ENABLED] == true } + suspend fun setIsPinOnIdleEnabled(value: Boolean) { store.edit { it[IS_PIN_ON_IDLE_ENABLED] = value } } + suspend fun wipe() { store.edit { it.clear() } Logger.info("Deleted all user settings data.") @@ -93,5 +96,6 @@ class SettingsStore @Inject constructor( private val IS_PIN_ENABLED = booleanPreferencesKey("is_pin_enabled") private val IS_PIN_ON_LAUNCH_ENABLED = booleanPreferencesKey("is_pin_on_launch_enabled") private val IS_BIOMETRIC_ENABLED = booleanPreferencesKey("is_biometric_enabled") + private val IS_PIN_ON_IDLE_ENABLED = booleanPreferencesKey("is_pin_on_idle_enabled") } } diff --git a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt index 230eb0da3..a621ad889 100644 --- a/app/src/main/java/to/bitkit/data/keychain/Keychain.kt +++ b/app/src/main/java/to/bitkit/data/keychain/Keychain.kt @@ -71,7 +71,7 @@ class Keychain @Inject constructor( } catch (_: Exception) { throw KeychainError.FailedToSave(key) } - Logger.info("Saved/updated in keychain: $key") + Logger.info("Upsert in keychain: $key") } suspend fun delete(key: String) { diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 607b122aa..343ce27f4 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.ForgotPinSheet +import to.bitkit.ui.components.InactivityTracker import to.bitkit.ui.components.ToastOverlay import to.bitkit.ui.onboarding.CreateWalletWithPassphraseScreen import to.bitkit.ui.onboarding.IntroScreen @@ -178,14 +179,16 @@ class MainActivity : FragmentActivity() { } } } else { - ContentView( - appViewModel = appViewModel, - walletViewModel = walletViewModel, - blocktankViewModel = blocktankViewModel, - currencyViewModel = currencyViewModel, - activityListViewModel = activityListViewModel, - transferViewModel = transferViewModel, - ) + InactivityTracker(appViewModel) { + ContentView( + appViewModel = appViewModel, + walletViewModel = walletViewModel, + blocktankViewModel = blocktankViewModel, + currencyViewModel = currencyViewModel, + activityListViewModel = activityListViewModel, + transferViewModel = transferViewModel, + ) + } val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() AnimatedVisibility( diff --git a/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt b/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt index 879f1e947..eda0731c8 100644 --- a/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt +++ b/app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt @@ -16,6 +16,7 @@ fun AuthCheckScreen( val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle() val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle() + val isPinOnIdleEnabled by app.isPinOnIdleEnabled.collectAsStateWithLifecycle() AuthCheckView( showLogoOnPin = route.showLogoOnPin, @@ -32,6 +33,10 @@ fun AuthCheckScreen( app.setIsPinOnLaunchEnabled(!isPinOnLaunchEnabled) } + AuthCheckAction.TOGGLE_PIN_ON_IDLE -> { + app.setIsPinOnIdleEnabled(!isPinOnIdleEnabled) + } + AuthCheckAction.DISABLE_PIN -> { app.removePin() } @@ -44,7 +49,8 @@ fun AuthCheckScreen( } object AuthCheckAction { - const val TOGGLE_PIN_ON_LAUNCH = "toggle_pin_on_launch" - const val TOGGLE_BIOMETRICS = "toggle_biometrics" - const val DISABLE_PIN = "disable_pin" + const val TOGGLE_PIN_ON_LAUNCH = "TOGGLE_PIN_ON_LAUNCH" + const val TOGGLE_BIOMETRICS = "TOGGLE_BIOMETRICS" + const val TOGGLE_PIN_ON_IDLE = "TOGGLE_PIN_ON_IDLE" + const val DISABLE_PIN = "DISABLE_PIN" } diff --git a/app/src/main/java/to/bitkit/ui/components/InactivityTracker.kt b/app/src/main/java/to/bitkit/ui/components/InactivityTracker.kt new file mode 100644 index 000000000..3b759c4fd --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/components/InactivityTracker.kt @@ -0,0 +1,100 @@ +package to.bitkit.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import to.bitkit.utils.Logger +import to.bitkit.viewmodels.AppViewModel + +private const val INACTIVITY_DELAY = 90_000L // 90 seconds + +@Composable +fun InactivityTracker( + app: AppViewModel, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + val lifecycleOwner = LocalLifecycleOwner.current + + val isPinEnabled by app.isPinEnabled.collectAsStateWithLifecycle() + val isPinOnIdleEnabled by app.isPinOnIdleEnabled.collectAsStateWithLifecycle() + val isAuthenticated by app.isAuthenticated.collectAsStateWithLifecycle() + + var inactivityJob by remember { mutableStateOf(null) } + + fun resetInactivityTimeout() { + inactivityJob?.cancel()?.also { + inactivityJob = null + } + if (isPinEnabled && isPinOnIdleEnabled && isAuthenticated) { + inactivityJob = scope.launch { + delay(INACTIVITY_DELAY) + Logger.debug("Inactivity timeout reached after ${INACTIVITY_DELAY / 1000}s, resetting isAuthenticated.") + app.setIsAuthenticated(false) + resetInactivityTimeout() + } + } + } + + LaunchedEffect(isAuthenticated, isPinEnabled, isPinOnIdleEnabled) { + if (isAuthenticated) { + resetInactivityTimeout() + } else { + inactivityJob?.cancel()?.also { + inactivityJob = null + } + } + } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> resetInactivityTimeout() + Lifecycle.Event.ON_PAUSE -> inactivityJob?.cancel()?.also { inactivityJob = null } + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + inactivityJob?.cancel()?.also { + inactivityJob = null + } + } + } + + Box( + modifier = modifier.let { baseModifier -> + if (isPinOnIdleEnabled) { + baseModifier.pointerInput(Unit) { + while (true) { + awaitPointerEventScope { + awaitPointerEvent() + resetInactivityTimeout() + } + } + } + } else { + baseModifier + } + } + ) { + content() + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt index 69da5a180..7e01f6a6d 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt @@ -43,6 +43,7 @@ fun SecuritySettingsScreen( val isPinEnabled by app.isPinEnabled.collectAsStateWithLifecycle() val isPinOnLaunchEnabled by app.isPinOnLaunchEnabled.collectAsStateWithLifecycle() val isBiometricEnabled by app.isBiometricEnabled.collectAsStateWithLifecycle() + val isPinOnIdleEnabled by app.isPinOnIdleEnabled.collectAsStateWithLifecycle() PinNavigationSheet( showSheet = showPinSheet, @@ -53,6 +54,7 @@ fun SecuritySettingsScreen( isPinEnabled = isPinEnabled, isPinOnLaunchEnabled = isPinOnLaunchEnabled, isBiometricEnabled = isBiometricEnabled, + isPinOnIdleEnabled = isPinOnIdleEnabled, isBiometrySupported = rememberBiometricAuthSupported(), onPinClick = { if (!isPinEnabled) { @@ -69,6 +71,11 @@ fun SecuritySettingsScreen( onSuccessActionId = AuthCheckAction.TOGGLE_PIN_ON_LAUNCH, ) }, + onPinOnIdleClick = { + navController.navigateToAuthCheck( + onSuccessActionId = AuthCheckAction.TOGGLE_PIN_ON_IDLE, + ) + }, onUseBiometricsClick = { navController.navigateToAuthCheck( requireBiometrics = true, @@ -86,10 +93,12 @@ private fun SecuritySettingsContent( isPinEnabled: Boolean, isPinOnLaunchEnabled: Boolean, isBiometricEnabled: Boolean, + isPinOnIdleEnabled: Boolean, isBiometrySupported: Boolean, onPinClick: () -> Unit = {}, onChangePinClick: () -> Unit = {}, onPinOnLaunchClick: () -> Unit = {}, + onPinOnIdleClick: () -> Unit = {}, onUseBiometricsClick: () -> Unit = {}, onBackClick: () -> Unit = {}, onCloseClick: () -> Unit = {}, @@ -122,10 +131,15 @@ private fun SecuritySettingsContent( isChecked = isPinOnLaunchEnabled, onClick = onPinOnLaunchClick, ) + SettingsSwitchRow( + title = stringResource(R.string.settings__security__pin_idle), + isChecked = isPinOnIdleEnabled, + onClick = onPinOnIdleClick, + ) } if (isPinEnabled && isBiometrySupported) { SettingsSwitchRow( - title = let { + title = run { val bioTypeName = stringResource(R.string.security__bio) stringResource(R.string.settings__security__use_bio).replace("{biometryTypeName}", bioTypeName) }, @@ -135,7 +149,7 @@ private fun SecuritySettingsContent( } if (isPinEnabled && isBiometrySupported) { BodyS( - text = let { + text = run { val bioTypeName = stringResource(R.string.security__bio) stringResource(R.string.settings__security__footer).replace("{biometryTypeName}", bioTypeName) }, @@ -155,6 +169,7 @@ fun Preview() { isPinEnabled = true, isPinOnLaunchEnabled = true, isBiometricEnabled = false, + isPinOnIdleEnabled = false, isBiometrySupported = true, ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt b/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt index 3f106e89e..b84547cd2 100644 --- a/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt +++ b/app/src/main/java/to/bitkit/ui/settings/pin/PinNavigationSheet.kt @@ -10,7 +10,6 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import kotlinx.serialization.Serializable -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.SheetHost @Composable @@ -20,7 +19,6 @@ fun PinNavigationSheet( onDismiss: () -> Unit = {}, content: @Composable () -> Unit, ) { - val app = appViewModel ?: return val navController = rememberNavController() SheetHost( @@ -88,20 +86,19 @@ fun PinNavigationSheet( ) } -@Serializable -sealed class PinRoute { +object PinRoute { @Serializable - data object PinPrompt : PinRoute() + data object PinPrompt @Serializable - data object ChoosePin : PinRoute() + data object ChoosePin @Serializable - data class ConfirmPin(val pin: String) : PinRoute() + data class ConfirmPin(val pin: String) @Serializable - data object AskForBiometrics : PinRoute() + data object AskForBiometrics @Serializable - data class Result(val isBioOn: Boolean) : PinRoute() + data class Result(val isBioOn: Boolean) } diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 1548c5ef5..dad552f69 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -126,6 +126,15 @@ class AppViewModel @Inject constructor( } } + val isPinOnIdleEnabled: StateFlow = settingsStore.isPinOnIdleEnabled + .stateIn(viewModelScope, SharingStarted.Lazily, false) + + fun setIsPinOnIdleEnabled(value: Boolean) { + viewModelScope.launch { + settingsStore.setIsPinOnIdleEnabled(value) + } + } + val isBiometricEnabled: StateFlow = settingsStore.isBiometricEnabled .stateIn(viewModelScope, SharingStarted.Eagerly, false) @@ -834,13 +843,13 @@ class AppViewModel @Inject constructor( fun removePin() { setIsPinEnabled(false) setIsPinOnLaunchEnabled(true) + setIsPinOnIdleEnabled(false) setIsBiometricEnabled(false) viewModelScope.launch { keychain.delete(Keychain.Key.PIN.name) keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, Env.PIN_ATTEMPTS.toString()) } - } // endregion } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae8ed836b..001b3e1d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,7 +81,7 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ldk-node-android = { module = "org.lightningdevkit:ldk-node-android", version.ref = "ldkNode" } -lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version = "lifecycle" } +lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycle" } lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }