Add Authenticator app-lock timeout (#6609)

This commit is contained in:
David Perez
2026-03-03 20:14:35 +00:00
committed by GitHub
parent d0dc4200f8
commit cf1455a45a
20 changed files with 957 additions and 54 deletions
@@ -5,6 +5,8 @@ import com.bitwarden.authenticator.data.authenticator.datasource.sdk.Authenticat
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.manager.lock.AppLockManager
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppLockState
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@@ -23,8 +25,10 @@ import javax.inject.Inject
/**
* Default implementation of [AuthRepository].
*/
@Suppress("LongParameterList")
class AuthRepositoryImpl @Inject constructor(
private val authDiskSource: AuthDiskSource,
private val settingsRepository: SettingsRepository,
private val authenticatorSdkSource: AuthenticatorSdkSource,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val realtimeManager: RealtimeManager,
@@ -64,6 +68,7 @@ class AuthRepositoryImpl @Inject constructor(
// Set app to unlocked to ensure we do not re-lock after saving the biometric key.
appLockManager.manualAppUnlock()
settingsRepository.appTimeoutState = AppTimeout.OnAppRestart
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = encryptedBiometricsKey)
authDiskSource.userBiometricKeyInitVector = cipher.iv
}
@@ -6,6 +6,7 @@ import com.bitwarden.authenticator.data.auth.repository.AuthRepositoryImpl
import com.bitwarden.authenticator.data.authenticator.datasource.sdk.AuthenticatorSdkSource
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.manager.lock.AppLockManager
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import dagger.Module
@@ -23,6 +24,7 @@ object AuthRepositoryModule {
@Provides
fun provideAuthRepository(
authDiskSource: AuthDiskSource,
settingsRepository: SettingsRepository,
authenticatorSdkSource: AuthenticatorSdkSource,
biometricsEncryptionManager: BiometricsEncryptionManager,
realtimeManager: RealtimeManager,
@@ -30,6 +32,7 @@ object AuthRepositoryModule {
appLockManager: AppLockManager,
): AuthRepository = AuthRepositoryImpl(
authDiskSource = authDiskSource,
settingsRepository = settingsRepository,
authenticatorSdkSource = authenticatorSdkSource,
biometricsEncryptionManager = biometricsEncryptionManager,
realtimeManager = realtimeManager,
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow
/**
* Primary access point for general settings-related disk information.
*/
@Suppress("TooManyFunctions")
interface SettingsDiskSource : FlightRecorderDiskSource {
/**
@@ -104,6 +105,17 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun getAlertThresholdSecondsFlow(): Flow<Int>
/**
* Gets or sets the app timeout in minutes.
*/
var appTimeoutInMinutes: Int?
/**
* Emits updates that track [appTimeoutInMinutes]. This will replay the last known value,
* if any.
*/
val appTimeoutInMinutesFlow: Flow<Int?>
/**
* Retrieves the biometric integrity validity for the given [systemBioIntegrityState].
*/
@@ -17,6 +17,7 @@ private const val DYNAMIC_COLORS_KEY = "dynamicColors"
private const val SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY = "biometricIntegritySource"
private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "accountBiometricIntegrityValid"
private const val ALERT_THRESHOLD_SECONDS_KEY = "alertThresholdSeconds"
private const val APP_TIMEOUT_IN_MINUTES_KEY = "appTimeoutInMinutes"
private const val FIRST_LAUNCH_KEY = "hasSeenWelcomeTutorial"
private const val CRASH_LOGGING_ENABLED_KEY = "crashLoggingEnabled"
private const val SCREEN_CAPTURE_ALLOW_KEY = "screenCaptureAllowed"
@@ -31,6 +32,7 @@ private const val DEFAULT_ALERT_THRESHOLD_SECONDS = 7
/**
* Primary implementation of [SettingsDiskSource].
*/
@Suppress("TooManyFunctions")
class SettingsDiskSourceImpl(
sharedPreferences: SharedPreferences,
flightRecorderDiskSource: FlightRecorderDiskSource,
@@ -46,6 +48,8 @@ class SettingsDiskSourceImpl(
private val mutableAlertThresholdSecondsFlow =
bufferedMutableSharedFlow<Int>()
private val mutableAppTimeoutInMinutesFlow = bufferedMutableSharedFlow<Int?>()
private val mutableIsCrashLoggingEnabledFlow =
bufferedMutableSharedFlow<Boolean?>()
@@ -179,6 +183,16 @@ class SettingsDiskSourceImpl(
override fun getAlertThresholdSecondsFlow(): Flow<Int> = mutableAlertThresholdSecondsFlow
.onSubscription { emit(getAlertThresholdSeconds()) }
override var appTimeoutInMinutes: Int?
get() = getInt(APP_TIMEOUT_IN_MINUTES_KEY)
set(value) {
putInt(APP_TIMEOUT_IN_MINUTES_KEY, value)
mutableAppTimeoutInMinutesFlow.tryEmit(value)
}
override val appTimeoutInMinutesFlow: Flow<Int?>
get() = mutableAppTimeoutInMinutesFlow.onSubscription { emit(appTimeoutInMinutes) }
override fun getAccountBiometricIntegrityValidity(
systemBioIntegrityState: String,
): Boolean? =
@@ -79,6 +79,7 @@ class BiometricsEncryptionManagerImpl(
)
}
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null)
settingsDiskSource.appTimeoutInMinutes = null
authDiskSource.userBiometricKeyInitVector = null
keystore.deleteEntry(ENCRYPTION_KEY_NAME)
}
@@ -31,6 +31,7 @@ import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.core.data.manager.realtime.RealtimeManagerImpl
import com.bitwarden.core.data.manager.toast.ToastManager
import com.bitwarden.core.data.manager.toast.ToastManagerImpl
import com.bitwarden.data.manager.appstate.AppStateManager
import com.bitwarden.data.repository.ServerConfigRepository
import dagger.Module
import dagger.Provides
@@ -49,11 +50,21 @@ object PlatformManagerModule {
@Provides
@Singleton
fun provideAppLockManager(
appStateManager: AppStateManager,
realtimeManager: RealtimeManager,
settingsRepository: SettingsRepository,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
dispatcherManager: DispatcherManager,
@ApplicationContext context: Context,
): AppLockManager = AppLockManagerImpl(
appStateManager = appStateManager,
realtimeManager = realtimeManager,
settingsRepository = settingsRepository,
authDiskSource = authDiskSource,
settingsDiskSource = settingsDiskSource,
dispatcherManager = dispatcherManager,
context = context,
)
@Provides
@@ -1,26 +1,77 @@
package com.bitwarden.authenticator.data.platform.manager.lock
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.bitwarden.authenticator.data.auth.datasource.disk.AuthDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppLockState
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.data.manager.appstate.AppStateManager
import com.bitwarden.data.manager.appstate.model.AppForegroundState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.minutes
/**
* The default implementation of the [AppLockManager].
*/
@Suppress("LongParameterList")
internal class AppLockManagerImpl(
private val appStateManager: AppStateManager,
private val realtimeManager: RealtimeManager,
private val settingsRepository: SettingsRepository,
authDiskSource: AuthDiskSource,
settingsDiskSource: SettingsDiskSource,
dispatcherManager: DispatcherManager,
context: Context,
) : AppLockManager {
private val unconfinedScope: CoroutineScope = CoroutineScope(dispatcherManager.unconfined)
private val internalHasUserUnlocked: MutableStateFlow<Boolean> = MutableStateFlow(false)
/**
* This tracks the timeout [Job] that is running and the associated data.
*/
private var timeoutJobData: TimeoutJobData? = null
init {
if (authDiskSource.getUserBiometricUnlockKey() != null &&
settingsDiskSource.appTimeoutInMinutes == null
) {
// Ensures that users who already had biometrics enabled before we added the session
// timeout feature will have the correct timeout state.
// This check must occur before the internalHasUserUnlocked is initialized.
settingsRepository.appTimeoutState = AppTimeout.OnAppRestart
}
}
private val internalHasUserUnlocked: MutableStateFlow<Boolean> = MutableStateFlow(
value = when (settingsRepository.appTimeoutState) {
AppTimeout.Never -> true
AppTimeout.OnAppRestart,
AppTimeout.Immediately,
AppTimeout.OneMinute,
AppTimeout.FiveMinutes,
AppTimeout.FifteenMinutes,
AppTimeout.ThirtyMinutes,
AppTimeout.OneHour,
AppTimeout.FourHours,
-> false
},
)
override val appLockStateFlow: StateFlow<AppLockState> = combine(
internalHasUserUnlocked,
@@ -40,6 +91,14 @@ internal class AppLockManagerImpl(
),
)
init {
observeAppForegroundChanges()
context.registerReceiver(
ScreenStateBroadcastReceiver(),
IntentFilter(Intent.ACTION_SCREEN_ON),
)
}
override fun manualAppUnlock() {
internalHasUserUnlocked.update { true }
}
@@ -52,10 +111,105 @@ internal class AppLockManagerImpl(
// The user has manually unlocked, so we are unlocked.
AppLockState.UNLOCKED
} else if (userBiometricUnlockKey != null) {
// The user has not yet manually unlocked and we have a biometric key so we are locked.
// The user has not yet manually unlocked, and we have a biometric key so we are locked.
AppLockState.LOCKED
} else {
// The user has not yet setup biometrics, so we cannot be locked.
AppLockState.UNLOCKED
}
private fun observeAppForegroundChanges() {
appStateManager
.appForegroundStateFlow
.onEach { appForegroundState ->
when (appForegroundState) {
AppForegroundState.BACKGROUNDED -> handleOnBackground()
AppForegroundState.FOREGROUNDED -> handleOnForeground()
}
}
.launchIn(unconfinedScope)
}
private fun handleOnBackground() {
when (val appTimeout = settingsRepository.appTimeoutState) {
AppTimeout.Never -> {
// Verify that there is no task to lock the app since we never lock.
clearJob()
}
AppTimeout.OnAppRestart -> {
// Verify that there is no task to lock the app while running, we will
// naturally lock the app on restart.
clearJob()
}
AppTimeout.Immediately,
AppTimeout.OneMinute,
AppTimeout.FiveMinutes,
AppTimeout.FifteenMinutes,
AppTimeout.ThirtyMinutes,
AppTimeout.OneHour,
AppTimeout.FourHours,
-> {
handleTimeoutActionWithDelay(
delayMs = appTimeout
.timeoutInMinutes
.minutes
.inWholeMilliseconds,
)
}
}
}
private fun handleOnForeground() {
clearJob()
}
private fun clearJob() {
timeoutJobData?.job?.cancel()
timeoutJobData = null
}
/**
* Locks the app after the [delayMs] has passed.
*/
private fun handleTimeoutActionWithDelay(delayMs: Long) {
timeoutJobData?.job?.cancel()
timeoutJobData = TimeoutJobData(
job = unconfinedScope.launch {
delay(timeMillis = delayMs)
timeoutJobData = null
internalHasUserUnlocked.update { false }
},
startTimeMs = realtimeManager.elapsedRealtimeMs,
durationMs = delayMs,
)
}
/**
* A custom [BroadcastReceiver] that listens for when the screen is powered on and restarts the
* timeout job to ensure they complete at the correct time.
*
* This is necessary because the [delay] function in a coroutine will not keep accurate time
* when the screen is off. We do not cancel the job when the screen is off, this allows the
* job to complete as-soon-as possible if the screen is powered off for an extended period.
*/
private inner class ScreenStateBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
timeoutJobData?.let {
val durationSoFarMs = (realtimeManager.elapsedRealtimeMs - it.startTimeMs)
.coerceAtLeast(minimumValue = 0L)
handleTimeoutActionWithDelay(delayMs = it.durationMs - durationSoFarMs)
}
}
}
/**
* A wrapper class containing all relevant data concerning a timeout action [Job].
*/
private data class TimeoutJobData(
val job: Job,
val startTimeMs: Long,
val durationMs: Long,
)
}
@@ -0,0 +1,117 @@
package com.bitwarden.authenticator.data.platform.manager.lock.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Represents the timeout period for the app.
*/
@Suppress("MagicNumber")
sealed class AppTimeout : Parcelable {
/**
* The type of timeout.
*/
abstract val type: Type
/**
* The time (in minutes) that the vault can stay unlocked before it will automatically lock
* itself.
*/
abstract val timeoutInMinutes: Int
/**
* The app should be considered timed-out immediately.
*/
@Parcelize
data object Immediately : AppTimeout() {
override val type: Type get() = Type.IMMEDIATELY
override val timeoutInMinutes: Int get() = 0
}
/**
* The app should time out after one minute.
*/
@Parcelize
data object OneMinute : AppTimeout() {
override val type: Type get() = Type.ONE_MINUTE
override val timeoutInMinutes: Int get() = 1
}
/**
* The app should time out after five minutes.
*/
@Parcelize
data object FiveMinutes : AppTimeout() {
override val type: Type get() = Type.FIVE_MINUTES
override val timeoutInMinutes: Int get() = 5
}
/**
* The app should time out after fifteen minutes.
*/
@Parcelize
data object FifteenMinutes : AppTimeout() {
override val type: Type get() = Type.FIFTEEN_MINUTES
override val timeoutInMinutes: Int get() = 15
}
/**
* The app should time out after thirty minutes.
*/
@Parcelize
data object ThirtyMinutes : AppTimeout() {
override val type: Type get() = Type.THIRTY_MINUTES
override val timeoutInMinutes: Int get() = 30
}
/**
* The app should time out after one hour.
*/
@Parcelize
data object OneHour : AppTimeout() {
override val type: Type get() = Type.ONE_HOUR
override val timeoutInMinutes: Int get() = 60
}
/**
* The app should time out after four hours.
*/
@Parcelize
data object FourHours : AppTimeout() {
override val type: Type get() = Type.FOUR_HOURS
override val timeoutInMinutes: Int get() = 240
}
/**
* The app should time out after an app restart.
*/
@Parcelize
data object OnAppRestart : AppTimeout() {
override val type: Type get() = Type.ON_APP_RESTART
override val timeoutInMinutes: Int get() = -1
}
/**
* The app should never automatically timeout.
*/
@Parcelize
data object Never : AppTimeout() {
override val type: Type get() = Type.NEVER
override val timeoutInMinutes: Int get() = -2
}
/**
* The specific type of timeout.
*/
enum class Type {
IMMEDIATELY,
ONE_MINUTE,
FIVE_MINUTES,
FIFTEEN_MINUTES,
THIRTY_MINUTES,
ONE_HOUR,
FOUR_HOURS,
ON_APP_RESTART,
NEVER,
}
}
@@ -1,5 +1,6 @@
package com.bitwarden.authenticator.data.platform.repository
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
@@ -101,4 +102,14 @@ interface SettingsRepository : FlightRecorderManager {
* Whether or not the user has previously dismissed the sync with Bitwarden action card.
*/
var hasUserDismissedSyncWithBitwardenCard: Boolean
/**
* Gets or sets the [AppTimeout].
*/
var appTimeoutState: AppTimeout
/**
* Gets updates for the [AppTimeout].
*/
val appTimeoutStateFlow: StateFlow<AppTimeout>
}
@@ -2,6 +2,7 @@ package com.bitwarden.authenticator.data.platform.repository
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@@ -142,4 +143,36 @@ class SettingsRepositoryImpl(
set(value) {
settingsDiskSource.hasUserDismissedSyncWithBitwardenCard = value
}
override var appTimeoutState: AppTimeout
get() = settingsDiskSource.appTimeoutInMinutes.toAppTimeout()
set(value) {
settingsDiskSource.appTimeoutInMinutes = value.timeoutInMinutes
}
override val appTimeoutStateFlow: StateFlow<AppTimeout> = settingsDiskSource
.appTimeoutInMinutesFlow
.map { it.toAppTimeout() }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = settingsDiskSource.appTimeoutInMinutes.toAppTimeout(),
)
}
/**
* Converts a stored [Int] representing an app timeout in minutes to a [AppTimeout].
*/
private fun Int?.toAppTimeout(): AppTimeout =
when (this) {
AppTimeout.Immediately.timeoutInMinutes -> AppTimeout.Immediately
AppTimeout.OneMinute.timeoutInMinutes -> AppTimeout.OneMinute
AppTimeout.FiveMinutes.timeoutInMinutes -> AppTimeout.FiveMinutes
AppTimeout.FifteenMinutes.timeoutInMinutes -> AppTimeout.FifteenMinutes
AppTimeout.ThirtyMinutes.timeoutInMinutes -> AppTimeout.ThirtyMinutes
AppTimeout.OneHour.timeoutInMinutes -> AppTimeout.OneHour
AppTimeout.FourHours.timeoutInMinutes -> AppTimeout.FourHours
AppTimeout.OnAppRestart.timeoutInMinutes -> AppTimeout.OnAppRestart
null -> AppTimeout.Never
else -> AppTimeout.Never
}
@@ -42,10 +42,12 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.ui.platform.components.biometrics.BiometricChanges
import com.bitwarden.authenticator.ui.platform.composition.LocalBiometricsManager
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.authenticator.ui.platform.feature.settings.security.util.displayLabel
import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager
import com.bitwarden.authenticator.ui.platform.util.displayLabel
import com.bitwarden.ui.platform.base.util.EventsEffect
@@ -64,6 +66,7 @@ import com.bitwarden.ui.platform.components.row.BitwardenTextRow
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState
import com.bitwarden.ui.platform.components.support.BitwardenSupportingText
import com.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
@@ -189,7 +192,6 @@ fun SettingsScreen(
) {
SecuritySettings(
state = state,
biometricsManager = biometricsManager,
onBiometricToggle = remember(viewModel) {
{
viewModel.trySendAction(
@@ -204,6 +206,9 @@ fun SettingsScreen(
)
}
},
onAppTimeoutChange = remember(viewModel) {
{ viewModel.trySendAction(SettingsAction.SecurityClick.AppTimeoutChange(it)) }
},
)
Spacer(modifier = Modifier.height(16.dp))
VaultSettings(
@@ -309,11 +314,12 @@ fun SettingsScreen(
//region Security settings
@Composable
private fun SecuritySettings(
private fun ColumnScope.SecuritySettings(
state: SettingsState,
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
onBiometricToggle: (Boolean) -> Unit,
onScreenCaptureChange: (Boolean) -> Unit,
onAppTimeoutChange: (AppTimeout.Type) -> Unit,
resources: Resources = LocalResources.current,
) {
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenListHeaderText(
@@ -323,26 +329,51 @@ private fun SecuritySettings(
label = stringResource(id = BitwardenString.security),
)
Spacer(modifier = Modifier.height(8.dp))
if (state.hasBiometricsSupport) {
UnlockWithBiometricsRow(
Spacer(modifier = Modifier.height(height = 8.dp))
BitwardenSwitch(
cardStyle = CardStyle.Top(),
label = stringResource(id = BitwardenString.lock_app),
isChecked = state.isUnlockWithBiometricsEnabled,
onCheckedChange = { onBiometricToggle(it) },
modifier = Modifier
.testTag("UnlockWithBiometricsSwitch")
.fillMaxWidth()
.standardHorizontalMargin(),
isChecked = state.isUnlockWithBiometricsEnabled,
onBiometricToggle = { onBiometricToggle(it) },
biometricsManager = biometricsManager,
)
BitwardenMultiSelectButton(
label = stringResource(id = BitwardenString.session_timeout),
options = AppTimeout.Type.entries.map { it.displayLabel() }.toImmutableList(),
selectedOption = state.appTimeout.type.displayLabel(),
onOptionSelected = { selectedType ->
val selectedOption = AppTimeout.Type.entries.first {
it.displayLabel.toString(resources) == selectedType
}
onAppTimeoutChange(selectedOption)
},
isEnabled = state.isUnlockWithBiometricsEnabled,
textFieldTestTag = "SessionTimeoutStatusLabel",
cardStyle = CardStyle.Middle(),
modifier = Modifier
.testTag("AppTimeoutSwitch")
.fillMaxWidth()
.standardHorizontalMargin(),
)
BitwardenSupportingText(
text = stringResource(
id = BitwardenString.use_your_devices_lock_method_to_unlock_the_app,
),
cardStyle = CardStyle.Bottom,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
Spacer(modifier = Modifier.height(height = 8.dp))
ScreenCaptureRow(
currentValue = state.allowScreenCapture,
cardStyle = if (state.hasBiometricsSupport) {
CardStyle.Bottom
} else {
CardStyle.Full
},
cardStyle = CardStyle.Full,
onValueChange = onScreenCaptureChange,
modifier = Modifier
.fillMaxWidth()
@@ -467,26 +498,6 @@ private fun DefaultSaveOptionSelectionRow(
)
}
@Composable
private fun UnlockWithBiometricsRow(
isChecked: Boolean,
onBiometricToggle: (Boolean) -> Unit,
biometricsManager: BiometricsManager,
modifier: Modifier = Modifier,
) {
if (!biometricsManager.isBiometricsSupported) return
BitwardenSwitch(
modifier = modifier,
cardStyle = CardStyle.Top(),
label = stringResource(id = BitwardenString.lock_app),
subtext = stringResource(
id = BitwardenString.use_your_devices_lock_method_to_unlock_the_app,
),
isChecked = isChecked,
onCheckedChange = onBiometricToggle,
)
}
@Composable
private fun ScreenCaptureRow(
currentValue: Boolean,
@@ -12,6 +12,7 @@ import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRe
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled
import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
@@ -70,6 +71,7 @@ class SettingsViewModel @Inject constructor(
sharedAccountsState = authenticatorRepository.sharedCodesStateFlow.value,
isScreenCaptureAllowed = settingsRepository.isScreenCaptureAllowed,
isDynamicColorsEnabled = settingsRepository.isDynamicColorsEnabled,
appTimeout = settingsRepository.appTimeoutState,
),
) {
@@ -94,6 +96,11 @@ class SettingsViewModel @Inject constructor(
.map { SettingsAction.Internal.UnlockWithBiometricsUpdated(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.appTimeoutStateFlow
.map { SettingsAction.Internal.AppTimeoutStateUpdated(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
snackbarRelayManager
.getSnackbarDataFlow(SnackbarRelay.IMPORT_SUCCESS)
.map(SettingsEvent::ShowSnackbar)
@@ -123,6 +130,16 @@ class SettingsViewModel @Inject constructor(
handleAboutClick(action)
}
is SettingsAction.BiometricSupportChanged -> {
handleBiometricSupportChanged(action)
}
is SettingsAction.Internal -> handleInternalAction(action)
}
}
private fun handleInternalAction(action: SettingsAction.Internal) {
when (action) {
is SettingsAction.Internal.BiometricsKeyResultReceive -> {
handleBiometricsKeyResultReceive(action)
}
@@ -141,8 +158,8 @@ class SettingsViewModel @Inject constructor(
handleUnlockWithBiometricsUpdated(action)
}
is SettingsAction.BiometricSupportChanged -> {
handleBiometricSupportChanged(action)
is SettingsAction.Internal.AppTimeoutStateUpdated -> {
handleAppTimeoutStateUpdated(action)
}
}
}
@@ -163,6 +180,12 @@ class SettingsViewModel @Inject constructor(
}
}
private fun handleAppTimeoutStateUpdated(
action: SettingsAction.Internal.AppTimeoutStateUpdated,
) {
mutableStateFlow.update { it.copy(appTimeout = action.appTimeout) }
}
private fun handleSharedAccountsStateUpdated(
action: SettingsAction.Internal.SharedAccountsStateUpdated,
) {
@@ -186,6 +209,8 @@ class SettingsViewModel @Inject constructor(
is SettingsAction.SecurityClick.UnlockWithBiometricToggleEnabled -> {
handleUnlockWithBiometricToggleEnabled(action)
}
is SettingsAction.SecurityClick.AppTimeoutChange -> handleAppTimeoutChange(action)
}
}
@@ -261,6 +286,20 @@ class SettingsViewModel @Inject constructor(
}
}
private fun handleAppTimeoutChange(action: SettingsAction.SecurityClick.AppTimeoutChange) {
settingsRepository.appTimeoutState = when (action.appTimeout) {
AppTimeout.Type.IMMEDIATELY -> AppTimeout.Immediately
AppTimeout.Type.ONE_MINUTE -> AppTimeout.OneMinute
AppTimeout.Type.FIVE_MINUTES -> AppTimeout.FiveMinutes
AppTimeout.Type.FIFTEEN_MINUTES -> AppTimeout.FifteenMinutes
AppTimeout.Type.THIRTY_MINUTES -> AppTimeout.ThirtyMinutes
AppTimeout.Type.ONE_HOUR -> AppTimeout.OneHour
AppTimeout.Type.FOUR_HOURS -> AppTimeout.FourHours
AppTimeout.Type.ON_APP_RESTART -> AppTimeout.OnAppRestart
AppTimeout.Type.NEVER -> AppTimeout.Never
}
}
private fun handleVaultClick(action: SettingsAction.DataClick) {
when (action) {
SettingsAction.DataClick.ExportClick -> handleExportClick()
@@ -425,6 +464,7 @@ class SettingsViewModel @Inject constructor(
sharedAccountsState: SharedVerificationCodesState,
isScreenCaptureAllowed: Boolean,
isDynamicColorsEnabled: Boolean,
appTimeout: AppTimeout,
): SettingsState {
val currentYear = Year.now(clock)
val copyrightInfo = "© Bitwarden Inc. 2015-$currentYear".asText()
@@ -453,6 +493,7 @@ class SettingsViewModel @Inject constructor(
showDefaultSaveOptionRow = shouldShowDefaultSaveOption,
allowScreenCapture = isScreenCaptureAllowed,
hasBiometricsSupport = true,
appTimeout = appTimeout,
)
}
}
@@ -474,6 +515,7 @@ data class SettingsState(
val version: Text,
val copyrightInfo: Text,
val allowScreenCapture: Boolean,
val appTimeout: AppTimeout,
) : Parcelable {
/**
@@ -617,6 +659,13 @@ sealed class SettingsAction {
* Indicates the user clicked allow screen capture toggle.
*/
data class AllowScreenCaptureToggle(val enabled: Boolean) : SecurityClick()
/**
* Indicates the user changed the app timeout setting.
*/
data class AppTimeoutChange(
val appTimeout: AppTimeout.Type,
) : SecurityClick()
}
/**
@@ -721,39 +770,46 @@ sealed class SettingsAction {
/**
* Models actions that the Settings screen itself may send.
*/
sealed class Internal {
sealed class Internal : SettingsAction() {
/**
* Indicates the biometrics key validation results has been received.
*/
data class BiometricsKeyResultReceive(val result: BiometricsKeyResult) : SettingsAction()
data class BiometricsKeyResultReceive(val result: BiometricsKeyResult) : Internal()
/**
* Indicates that shared account state was updated.
*/
data class SharedAccountsStateUpdated(
val state: SharedVerificationCodesState,
) : SettingsAction()
) : Internal()
/**
* Indicates that the default save option on disk was updated.
*/
data class DefaultSaveOptionUpdated(
val option: DefaultSaveOption,
) : SettingsAction()
) : Internal()
/**
* Indicates that the dynamic colors state on disk was updated.
*/
data class DynamicColorsUpdated(
val isEnabled: Boolean,
) : SettingsAction()
) : Internal()
/**
* Indicates that the biometric state on disk was updated.
*/
data class UnlockWithBiometricsUpdated(
val isEnabled: Boolean,
) : SettingsAction()
) : Internal()
/**
* Indicates that the app timeout state on disk was updated.
*/
data class AppTimeoutStateUpdated(
val appTimeout: AppTimeout,
) : Internal()
}
}
@@ -0,0 +1,23 @@
package com.bitwarden.authenticator.ui.platform.feature.settings.security.util
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
/**
* Provides a human-readable display label for the given [AppTimeout.Type].
*/
val AppTimeout.Type.displayLabel: Text
get() = when (this) {
AppTimeout.Type.IMMEDIATELY -> BitwardenString.immediately
AppTimeout.Type.ONE_MINUTE -> BitwardenString.one_minute
AppTimeout.Type.FIVE_MINUTES -> BitwardenString.five_minutes
AppTimeout.Type.FIFTEEN_MINUTES -> BitwardenString.fifteen_minutes
AppTimeout.Type.THIRTY_MINUTES -> BitwardenString.thirty_minutes
AppTimeout.Type.ONE_HOUR -> BitwardenString.one_hour
AppTimeout.Type.FOUR_HOURS -> BitwardenString.four_hours
AppTimeout.Type.ON_APP_RESTART -> BitwardenString.on_restart
AppTimeout.Type.NEVER -> BitwardenString.never
}
.asText()
@@ -6,6 +6,8 @@ import com.bitwarden.authenticator.data.authenticator.datasource.sdk.Authenticat
import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager
import com.bitwarden.authenticator.data.platform.manager.lock.AppLockManager
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppLockState
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult
import com.bitwarden.authenticator.data.platform.repository.model.BiometricsUnlockResult
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
@@ -20,9 +22,11 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.runs
import io.mockk.unmockkConstructor
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@@ -33,6 +37,7 @@ import javax.crypto.Cipher
class AuthRepositoryTest {
private val authDiskSource: FakeAuthDiskSource = FakeAuthDiskSource()
private val settingsRepository: SettingsRepository = mockk()
private val authenticatorSdkSource: AuthenticatorSdkSource = mockk()
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk()
private val realtimeManager: RealtimeManager = mockk()
@@ -43,6 +48,7 @@ class AuthRepositoryTest {
private val authRepository: AuthRepository = AuthRepositoryImpl(
authDiskSource = authDiskSource,
settingsRepository = settingsRepository,
authenticatorSdkSource = authenticatorSdkSource,
biometricsEncryptionManager = biometricsEncryptionManager,
realtimeManager = realtimeManager,
@@ -58,6 +64,11 @@ class AuthRepositoryTest {
} returns true
}
@AfterEach
fun tearDown() {
unmockkConstructor(MissingPropertyException::class)
}
@Test
fun `isUnlockWithBiometricsEnabled should update based on AuthDiskSource`() {
assertFalse(authRepository.isUnlockWithBiometricsEnabled)
@@ -120,6 +131,7 @@ class AuthRepositoryTest {
coEvery { authenticatorSdkSource.generateBiometricsKey() } returns biometricsKey.asSuccess()
every { CIPHER.doFinal(any()) } returns encryptedBytes
every { CIPHER.iv } returns iv
every { settingsRepository.appTimeoutState = AppTimeout.OnAppRestart } just runs
val result = authRepository.setupBiometricsKey(cipher = CIPHER)
@@ -131,6 +143,7 @@ class AuthRepositoryTest {
}
verify(exactly = 1) {
appLockManager.manualAppUnlock()
settingsRepository.appTimeoutState = AppTimeout.OnAppRestart
}
}
@@ -130,4 +130,19 @@ class SettingDiskSourceTest {
settingDiskSource.previouslySyncedBitwardenAccountIds,
)
}
@Test
fun `appTimeoutInMinutes should read and write from shared preferences`() {
val sharedPrefsKey = "bwPreferencesStorage:appTimeoutInMinutes"
// Shared preferences and the disk source start with the same value:
assertNull(settingDiskSource.appTimeoutInMinutes)
// Updating the disk source updates shared preferences:
settingDiskSource.appTimeoutInMinutes = 60
assertEquals(sharedPreferences.getInt(sharedPrefsKey, 0), 60)
sharedPreferences.edit { putInt(sharedPrefsKey, 240) }
assertEquals(settingDiskSource.appTimeoutInMinutes, 240)
}
}
@@ -1,26 +1,79 @@
package com.bitwarden.authenticator.data.platform.manager.lock
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import app.cash.turbine.test
import com.bitwarden.authenticator.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppLockState
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.manager.realtime.RealtimeManager
import com.bitwarden.data.manager.appstate.FakeAppStateManager
import com.bitwarden.data.manager.appstate.model.AppForegroundState
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@OptIn(ExperimentalCoroutinesApi::class)
class AppLockManagerTest {
private val broadcastReceiver = slot<BroadcastReceiver>()
private val context: Context = mockk {
@SuppressLint("UnspecifiedRegisterReceiverFlag")
every { registerReceiver(capture(broadcastReceiver), any()) } returns null
}
private val unconfinedDispatcher = UnconfinedTestDispatcher()
private val appStateManager = FakeAppStateManager(
appForegroundState = AppForegroundState.FOREGROUNDED,
)
private val realtimeManager: RealtimeManager = mockk()
private val settingsRepository: SettingsRepository = mockk()
private val authDiskSource = FakeAuthDiskSource()
private val settingsDiskSource: SettingsDiskSource = mockk {
every { appTimeoutInMinutes } returns -1
}
private val appLockManager: AppLockManager = AppLockManagerImpl(
private fun createAppLockManager(): AppLockManager = AppLockManagerImpl(
appStateManager = appStateManager,
realtimeManager = realtimeManager,
settingsRepository = settingsRepository,
authDiskSource = authDiskSource,
dispatcherManager = FakeDispatcherManager(),
settingsDiskSource = settingsDiskSource,
dispatcherManager = FakeDispatcherManager(unconfined = unconfinedDispatcher),
context = context,
)
@Test
fun `appLockStateFlow should update according to internal state changes`() = runTest {
// The app has a key, so it should start out locked.
fun `On initialization, should migrate user to OnAppRestart`() {
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
every { settingsDiskSource.appTimeoutInMinutes } returns null
every { settingsRepository.appTimeoutState } returns AppTimeout.OnAppRestart
every { settingsRepository.appTimeoutState = AppTimeout.OnAppRestart } just runs
// We just need to make sure the init block runs
createAppLockManager()
verify(exactly = 1) {
settingsRepository.appTimeoutState = AppTimeout.OnAppRestart
}
}
@Test
fun `appLockStateFlow should update according to internal state changes`() = runTest {
every { settingsRepository.appTimeoutState } returns AppTimeout.OneMinute
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
appLockManager.appLockStateFlow.test {
assertEquals(AppLockState.LOCKED, awaitItem())
@@ -41,9 +94,158 @@ class AppLockManagerTest {
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null)
expectNoEvents()
// Resetting the biometric key should keep the app is unlocked.
// Resetting the biometric key should keep the app unlocked.
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `appLockStateFlow initial state when timeout is Never should be UNLOCKED with biometric key`() {
every { settingsRepository.appTimeoutState } returns AppTimeout.Never
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `appLockStateFlow initial state when timeout is not Never and biometric key exists should be LOCKED`() {
every { settingsRepository.appTimeoutState } returns AppTimeout.OneMinute
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
assertEquals(AppLockState.LOCKED, appLockManager.appLockStateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `appLockStateFlow initial state when no biometric key should be UNLOCKED regardless of timeout`() {
every { settingsRepository.appTimeoutState } returns AppTimeout.OneMinute
val appLockManager = createAppLockManager()
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
}
@Test
fun `on background when timeout is Never should not start a lock timer`() {
every { settingsRepository.appTimeoutState } returns AppTimeout.Never
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
appStateManager.appForegroundState = AppForegroundState.BACKGROUNDED
unconfinedDispatcher.scheduler.advanceTimeBy(delayTimeMillis = ONE_MINUTE_MS * 10)
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
}
@Test
fun `on background when timeout is OnAppRestart should not start a lock timer`() {
every { settingsRepository.appTimeoutState } returns AppTimeout.OnAppRestart
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
appLockManager.manualAppUnlock()
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
appStateManager.appForegroundState = AppForegroundState.BACKGROUNDED
unconfinedDispatcher.scheduler.advanceTimeBy(delayTimeMillis = ONE_MINUTE_MS * 10)
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
}
@Test
fun `on background when timeout is Immediately should lock app`() {
every { settingsRepository.appTimeoutState } returns AppTimeout.Immediately
every { realtimeManager.elapsedRealtimeMs } returns 0L
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
appLockManager.manualAppUnlock()
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
appStateManager.appForegroundState = AppForegroundState.BACKGROUNDED
assertEquals(AppLockState.LOCKED, appLockManager.appLockStateFlow.value)
}
@Test
fun `on background when timeout is OneMinute should lock app after one minute`() = runTest {
every { settingsRepository.appTimeoutState } returns AppTimeout.OneMinute
every { realtimeManager.elapsedRealtimeMs } returns 0L
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
appLockManager.appLockStateFlow.test {
assertEquals(AppLockState.LOCKED, awaitItem())
appLockManager.manualAppUnlock()
assertEquals(AppLockState.UNLOCKED, awaitItem())
appStateManager.appForegroundState = AppForegroundState.BACKGROUNDED
expectNoEvents()
unconfinedDispatcher.scheduler.advanceTimeBy(delayTimeMillis = ONE_MINUTE_MS + 1L)
assertEquals(AppLockState.LOCKED, awaitItem())
}
}
@Test
fun `on foreground before timeout fires should cancel pending lock`() {
every { settingsRepository.appTimeoutState } returns AppTimeout.OneMinute
every { realtimeManager.elapsedRealtimeMs } returns 0L
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
appLockManager.manualAppUnlock()
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
appStateManager.appForegroundState = AppForegroundState.BACKGROUNDED
appStateManager.appForegroundState = AppForegroundState.FOREGROUNDED
// Advance past the original timeout — job was canceled so no lock should occur.
unconfinedDispatcher.scheduler.advanceTimeBy(delayTimeMillis = ONE_MINUTE_MS)
assertEquals(AppLockState.UNLOCKED, appLockManager.appLockStateFlow.value)
}
@Test
fun `on screen on event should restart timeout with remaining duration`() = runTest {
every { settingsRepository.appTimeoutState } returns AppTimeout.OneMinute
every { realtimeManager.elapsedRealtimeMs } returns 0L
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = "biometricsKey")
val appLockManager = createAppLockManager()
appLockManager.appLockStateFlow.test {
assertEquals(AppLockState.LOCKED, awaitItem())
appLockManager.manualAppUnlock()
assertEquals(AppLockState.UNLOCKED, awaitItem())
// App goes to background at realtime=0 ms, starting a 60 seconds lock timer.
appStateManager.appForegroundState = AppForegroundState.BACKGROUNDED
expectNoEvents()
// Screen turns back on after 30 seconds of real time have elapsed.
every { realtimeManager.elapsedRealtimeMs } returns THIRTY_SECONDS_MS
broadcastReceiver.captured.onReceive(context, Intent(Intent.ACTION_SCREEN_ON))
// Advancing 30 seconds should not trigger a lock, since it needs to surpass 30.
unconfinedDispatcher.scheduler.advanceTimeBy(delayTimeMillis = THIRTY_SECONDS_MS)
expectNoEvents()
// Advancing another millisecond should trigger the lock.
unconfinedDispatcher.scheduler.advanceTimeBy(delayTimeMillis = 1L)
assertEquals(AppLockState.LOCKED, awaitItem())
}
}
}
private const val ONE_MINUTE_MS: Long = 60_000L
private const val THIRTY_SECONDS_MS: Long = 30_000L
@@ -2,6 +2,7 @@ package com.bitwarden.authenticator.data.platform.repository
import app.cash.turbine.test
import com.bitwarden.authenticator.data.platform.datasource.disk.SettingsDiskSource
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
@@ -10,6 +11,7 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
@@ -19,8 +21,11 @@ import org.junit.jupiter.api.Test
class SettingsRepositoryTest {
private val mutableAppTimeoutInMinutesFlow = MutableStateFlow<Int?>(null)
private val settingsDiskSource: SettingsDiskSource = mockk {
every { getAlertThresholdSeconds() } returns 7
every { appTimeoutInMinutesFlow } returns mutableAppTimeoutInMinutesFlow
every { appTimeoutInMinutes } answers { mutableAppTimeoutInMinutesFlow.value }
}
private val settingsRepository: SettingsRepository = SettingsRepositoryImpl(
@@ -139,6 +144,30 @@ class SettingsRepositoryTest {
}
}
@Test
fun `appTimeoutState should pull from and update SettingsDiskSource`() {
// Reading from repository should read from disk source:
assertEquals(AppTimeout.Never, settingsRepository.appTimeoutState)
verify { settingsDiskSource.appTimeoutInMinutes }
// Writing to repository should write to disk source:
every { settingsDiskSource.appTimeoutInMinutes = 5 } just runs
settingsRepository.appTimeoutState = AppTimeout.FiveMinutes
verify { settingsDiskSource.appTimeoutInMinutes = 5 }
}
@Test
fun `appTimeoutStateFlow should match SettingsDiskSource`() = runTest {
settingsRepository.appTimeoutStateFlow.test {
assertEquals(AppTimeout.Never, awaitItem())
mutableAppTimeoutInMinutesFlow.emit(1)
assertEquals(AppTimeout.OneMinute, awaitItem())
mutableAppTimeoutInMinutesFlow.emit(240)
assertEquals(AppTimeout.FourHours, awaitItem())
expectNoEvents()
}
}
@Test
fun `previouslySyncedBitwardenAccountIds should pull from and update SettingsDiskSource`() {
// Reading from repository should read from disk source:
@@ -1,7 +1,9 @@
package com.bitwarden.authenticator.ui.platform.feature.settings
import android.content.Intent
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
@@ -12,6 +14,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.bitwarden.authenticator.BuildConfig
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.ui.platform.base.AuthenticatorComposeTest
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
@@ -33,13 +36,14 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class SettingsScreenTest : AuthenticatorComposeTest() {
private var onNavigateToTutorialCalled = false
private var onNaviateToExportCalled = false
private var onNavigateToExportCalled = false
private var onNavigateToImportCalled = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@@ -65,12 +69,30 @@ class SettingsScreenTest : AuthenticatorComposeTest() {
SettingsScreen(
viewModel = viewModel,
onNavigateToTutorial = { onNavigateToTutorialCalled = true },
onNavigateToExport = { onNaviateToExportCalled = true },
onNavigateToExport = { onNavigateToExportCalled = true },
onNavigateToImport = { onNavigateToImportCalled = true },
)
}
}
@Test
fun `NavigateToTutorial event should invoke onNavigateToTutorial`() {
mutableEventFlow.tryEmit(SettingsEvent.NavigateToTutorial)
assertTrue(onNavigateToTutorialCalled)
}
@Test
fun `NavigateToExport event should invoke onNavigateToExport`() {
mutableEventFlow.tryEmit(SettingsEvent.NavigateToExport)
assertTrue(onNavigateToExportCalled)
}
@Test
fun `NavigateToImport event should invoke onNavigateToImport`() {
mutableEventFlow.tryEmit(SettingsEvent.NavigateToImport)
assertTrue(onNavigateToImportCalled)
}
@Test
fun `Sync with Bitwarden row should be hidden when showSyncWithBitwarden is false`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
@@ -271,6 +293,67 @@ class SettingsScreenTest : AuthenticatorComposeTest() {
.onNodeWithText("Use your devices lock method to unlock the app")
.assertDoesNotExist()
}
@Test
fun `Session timeout row should be hidden when hasBiometricsSupport is false`() {
mutableStateFlow.value = DEFAULT_STATE
composeTestRule
.onNodeWithText("Session timeout")
.assertExists()
mutableStateFlow.update { it.copy(hasBiometricsSupport = false) }
composeTestRule
.onNodeWithText("Session timeout")
.assertDoesNotExist()
}
@Test
fun `Session timeout row should be disabled when isUnlockWithBiometricsEnabled is false`() {
mutableStateFlow.value = DEFAULT_STATE
composeTestRule
.onNodeWithText("Session timeout")
.assertExists()
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) }
composeTestRule
.onNodeWithText("Session timeout")
.assertIsNotEnabled()
}
@Test
fun `Session timeout row should display dialog when clicked`() {
mutableStateFlow.value = DEFAULT_STATE
composeTestRule
.onNodeWithText("Session timeout")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText("Session timeout")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `Session timeout dialog should emit action when row clicked`() {
mutableStateFlow.value = DEFAULT_STATE
composeTestRule
.onNodeWithText("Session timeout")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("1 hour")
.performScrollTo()
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(AppTimeout.OneHour.type),
)
}
}
}
private val APP_LANGUAGE = AppLanguage.ENGLISH
@@ -294,4 +377,5 @@ private val DEFAULT_STATE = SettingsState(
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
allowScreenCapture = false,
hasBiometricsSupport = true,
appTimeout = AppTimeout.OnAppRestart,
)
@@ -9,6 +9,7 @@ import com.bitwarden.authenticator.data.authenticator.repository.AuthenticatorRe
import com.bitwarden.authenticator.data.authenticator.repository.model.SharedVerificationCodesState
import com.bitwarden.authenticator.data.authenticator.repository.util.isSyncWithBitwardenEnabled
import com.bitwarden.authenticator.data.platform.manager.clipboard.BitwardenClipboardManager
import com.bitwarden.authenticator.data.platform.manager.lock.model.AppTimeout
import com.bitwarden.authenticator.data.platform.repository.SettingsRepository
import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage
import com.bitwarden.authenticator.ui.platform.feature.settings.data.model.DefaultSaveOption
@@ -60,6 +61,7 @@ class SettingsViewModelTest : BaseViewModelTest() {
every { isUnlockWithBiometricsEnabled } returns true
every { isUnlockWithBiometricsEnabledFlow } returns mutableIsUnlockWithBiometricsEnabledFlow
}
private val mutableAppTimeoutStateFlow = MutableStateFlow<AppTimeout>(AppTimeout.OnAppRestart)
private val settingsRepository: SettingsRepository = mockk {
every { appLanguage } returns APP_LANGUAGE
every { appTheme } returns APP_THEME
@@ -72,6 +74,9 @@ class SettingsViewModelTest : BaseViewModelTest() {
every { isDynamicColorsEnabled } answers { mutableIsDynamicColorsEnabledFlow.value }
every { isDynamicColorsEnabled = any() } just runs
every { isDynamicColorsEnabledFlow } returns mutableIsDynamicColorsEnabledFlow
every { appTimeoutState = any() } just runs
every { appTimeoutStateFlow } returns mutableAppTimeoutStateFlow
every { appTimeoutState } answers { mutableAppTimeoutStateFlow.value }
}
private val clipboardManager: BitwardenClipboardManager = mockk()
private val mutableSnackbarFlow = bufferedMutableSharedFlow<BitwardenSnackbarData>()
@@ -304,6 +309,107 @@ class SettingsViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `on AppTimeoutStateUpdated should update value in state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(
SettingsAction.Internal.AppTimeoutStateUpdated(appTimeout = AppTimeout.Immediately),
)
assertEquals(
DEFAULT_STATE.copy(appTimeout = AppTimeout.Immediately),
awaitItem(),
)
viewModel.trySendAction(
SettingsAction.Internal.AppTimeoutStateUpdated(appTimeout = AppTimeout.OneMinute),
)
assertEquals(
DEFAULT_STATE.copy(appTimeout = AppTimeout.OneMinute),
awaitItem(),
)
}
}
@Test
fun `on AppTimeoutChange should update value in state`() = runTest {
val viewModel = createViewModel()
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(appTimeout = AppTimeout.Type.IMMEDIATELY),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.Immediately
}
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(appTimeout = AppTimeout.Type.ONE_MINUTE),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.OneMinute
}
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(
appTimeout = AppTimeout.Type.FIVE_MINUTES,
),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.FiveMinutes
}
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(
appTimeout = AppTimeout.Type.FIFTEEN_MINUTES,
),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.FifteenMinutes
}
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(
appTimeout = AppTimeout.Type.THIRTY_MINUTES,
),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.ThirtyMinutes
}
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(appTimeout = AppTimeout.Type.ONE_HOUR),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.OneHour
}
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(appTimeout = AppTimeout.Type.FOUR_HOURS),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.FourHours
}
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(
appTimeout = AppTimeout.Type.ON_APP_RESTART,
),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.OnAppRestart
}
viewModel.trySendAction(
SettingsAction.SecurityClick.AppTimeoutChange(appTimeout = AppTimeout.Type.NEVER),
)
verify {
settingsRepository.appTimeoutState = AppTimeout.Never
}
}
private fun createViewModel(
savedState: SettingsState? = DEFAULT_STATE,
): SettingsViewModel = SettingsViewModel(
@@ -345,4 +451,5 @@ private val DEFAULT_STATE = SettingsState(
copyrightInfo = "© Bitwarden Inc. 2015-2024".asText(),
allowScreenCapture = false,
hasBiometricsSupport = true,
appTimeout = AppTimeout.OnAppRestart,
)
@@ -9,10 +9,12 @@ import kotlinx.coroutines.flow.asStateFlow
/**
* A faked implementation of [AppStateManager]
*/
class FakeAppStateManager : AppStateManager {
private val mutableAppCreationStateFlow =
MutableStateFlow<AppCreationState>(AppCreationState.Destroyed)
private val mutableAppForegroundStateFlow = MutableStateFlow(AppForegroundState.BACKGROUNDED)
class FakeAppStateManager(
appForegroundState: AppForegroundState = AppForegroundState.BACKGROUNDED,
appCreationState: AppCreationState = AppCreationState.Destroyed,
) : AppStateManager {
private val mutableAppCreationStateFlow = MutableStateFlow(appCreationState)
private val mutableAppForegroundStateFlow = MutableStateFlow(appForegroundState)
override val appCreatedStateFlow: StateFlow<AppCreationState>
get() = mutableAppCreationStateFlow.asStateFlow()