Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/main/java/to/bitkit/data/SettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ class SettingsStore @Inject constructor(
val isBiometricEnabled: Flow<Boolean> = store.data.map { it[IS_BIOMETRIC_ENABLED] == true }
suspend fun setIsBiometricEnabled(value: Boolean) { store.edit { it[IS_BIOMETRIC_ENABLED] = value } }

val isPinOnIdleEnabled: Flow<Boolean> = 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.")
Expand All @@ -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")
}
}
2 changes: 1 addition & 1 deletion app/src/main/java/to/bitkit/data/keychain/Keychain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 11 additions & 8 deletions app/src/main/java/to/bitkit/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
12 changes: 9 additions & 3 deletions app/src/main/java/to/bitkit/ui/components/AuthCheckScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +33,10 @@ fun AuthCheckScreen(
app.setIsPinOnLaunchEnabled(!isPinOnLaunchEnabled)
}

AuthCheckAction.TOGGLE_PIN_ON_IDLE -> {
app.setIsPinOnIdleEnabled(!isPinOnIdleEnabled)
}

AuthCheckAction.DISABLE_PIN -> {
app.removePin()
}
Expand All @@ -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"
}
100 changes: 100 additions & 0 deletions app/src/main/java/to/bitkit/ui/components/InactivityTracker.kt
Original file line number Diff line number Diff line change
@@ -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<Job?>(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()
}
}
19 changes: 17 additions & 2 deletions app/src/main/java/to/bitkit/ui/settings/SecuritySettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -53,6 +54,7 @@ fun SecuritySettingsScreen(
isPinEnabled = isPinEnabled,
isPinOnLaunchEnabled = isPinOnLaunchEnabled,
isBiometricEnabled = isBiometricEnabled,
isPinOnIdleEnabled = isPinOnIdleEnabled,
isBiometrySupported = rememberBiometricAuthSupported(),
onPinClick = {
if (!isPinEnabled) {
Expand All @@ -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,
Expand All @@ -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 = {},
Expand Down Expand Up @@ -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)
},
Expand All @@ -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)
},
Expand All @@ -155,6 +169,7 @@ fun Preview() {
isPinEnabled = true,
isPinOnLaunchEnabled = true,
isBiometricEnabled = false,
isPinOnIdleEnabled = false,
isBiometrySupported = true,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,7 +19,6 @@ fun PinNavigationSheet(
onDismiss: () -> Unit = {},
content: @Composable () -> Unit,
) {
val app = appViewModel ?: return
val navController = rememberNavController()

SheetHost(
Expand Down Expand Up @@ -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)
}
11 changes: 10 additions & 1 deletion app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ class AppViewModel @Inject constructor(
}
}

val isPinOnIdleEnabled: StateFlow<Boolean> = settingsStore.isPinOnIdleEnabled
.stateIn(viewModelScope, SharingStarted.Lazily, false)

fun setIsPinOnIdleEnabled(value: Boolean) {
viewModelScope.launch {
settingsStore.setIsPinOnIdleEnabled(value)
}
}

val isBiometricEnabled: StateFlow<Boolean> = settingsStore.isBiometricEnabled
.stateIn(viewModelScope, SharingStarted.Eagerly, false)

Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down