mirror of
https://github.com/bitwarden/android.git
synced 2026-08-28 04:32:25 -05:00
Add Authenticator app-lock timeout (#6609)
This commit is contained in:
+5
@@ -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
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+12
@@ -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].
|
||||
*/
|
||||
|
||||
+14
@@ -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? =
|
||||
|
||||
+1
@@ -79,6 +79,7 @@ class BiometricsEncryptionManagerImpl(
|
||||
)
|
||||
}
|
||||
authDiskSource.storeUserBiometricUnlockKey(biometricsKey = null)
|
||||
settingsDiskSource.appTimeoutInMinutes = null
|
||||
authDiskSource.userBiometricKeyInitVector = null
|
||||
keystore.deleteEntry(ENCRYPTION_KEY_NAME)
|
||||
}
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+156
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+117
@@ -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,
|
||||
}
|
||||
}
|
||||
+11
@@ -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>
|
||||
}
|
||||
|
||||
+33
@@ -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
|
||||
}
|
||||
|
||||
+44
-33
@@ -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,
|
||||
|
||||
+64
-8
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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()
|
||||
+13
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+207
-5
@@ -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
|
||||
|
||||
+29
@@ -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:
|
||||
|
||||
+86
-2
@@ -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 device’s 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,
|
||||
)
|
||||
|
||||
+107
@@ -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,
|
||||
)
|
||||
|
||||
+6
-4
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user