[PM-13626] Remember last opened view for 5 minutes (#4574)

Signed-off-by: Andre Rosado <arosado@bitwarden.com>
Co-authored-by: Dave Severns <dseverns@livefront.com>
This commit is contained in:
aj-rosado 2025-01-28 19:41:11 +00:00 committed by GitHub
parent 3f1f9983e3
commit fe06bf48e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1042 additions and 36 deletions

View File

@ -11,6 +11,7 @@ import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -19,6 +20,7 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityComp
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
@ -53,6 +55,7 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var debugLaunchManager: DebugMenuLaunchManager
@Suppress("LongMethod")
override fun onCreate(savedInstanceState: Bundle?) {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
@ -109,6 +112,15 @@ class MainActivity : AppCompatActivity() {
}
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider {
ObserveScreenDataEffect(
onDataUpdate = remember(mainViewModel) {
{
mainViewModel.trySendAction(
MainAction.ResumeScreenDataReceived(it),
)
}
},
)
BitwardenTheme(theme = state.theme) {
RootNavScreen(
onSplashScreenRemoved = { shouldShowSplashScreen = false },

View File

@ -18,8 +18,10 @@ import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2GetCredentialsReques
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
@ -71,6 +73,7 @@ class MainViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
private val savedStateHandle: SavedStateHandle,
private val appResumeManager: AppResumeManager,
private val clock: Clock,
) : BaseViewModel<MainState, MainEvent, MainAction>(
initialState = MainState(
@ -185,6 +188,14 @@ class MainViewModel @Inject constructor(
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
is MainAction.ResumeScreenDataReceived -> handleAppResumeDataUpdated(action)
}
}
private fun handleAppResumeDataUpdated(action: MainAction.ResumeScreenDataReceived) {
when (val data = action.screenResumeData) {
null -> appResumeManager.clearResumeScreen()
else -> appResumeManager.setResumeScreen(data)
}
}
@ -454,6 +465,11 @@ sealed class MainAction {
*/
data object OpenDebugMenu : MainAction()
/**
* Receive event to save the app resume screen
*/
data class ResumeScreenDataReceived(val screenResumeData: AppResumeScreenData?) : MainAction()
/**
* Actions for internal use by the ViewModel.
*/

View File

@ -7,6 +7,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJso
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import kotlinx.coroutines.flow.Flow
import java.time.Instant
/**
* Primary access point for disk information.
@ -352,4 +353,14 @@ interface AuthDiskSource {
* Stores the new device notice state for the given [userId].
*/
fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?)
/**
* Gets the last lock timestamp for the given [userId].
*/
fun getLastLockTimestamp(userId: String): Instant?
/**
* Stores the last lock timestamp for the given [userId].
*/
fun storeLastLockTimestamp(userId: String, lastLockTimestamp: Instant?)
}

View File

@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
// These keys should be encrypted
@ -49,6 +50,7 @@ private const val USES_KEY_CONNECTOR = "usesKeyConnector"
private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
private const val NEW_DEVICE_NOTICE_STATE = "newDeviceNoticeState"
private const val LAST_LOCK_TIMESTAMP = "lastLockTimestamp"
/**
* Primary implementation of [AuthDiskSource].
@ -154,6 +156,7 @@ class AuthDiskSourceImpl(
storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = null)
storeAuthenticatorSyncUnlockKey(userId = userId, authenticatorSyncUnlockKey = null)
storeShowImportLogins(userId = userId, showImportLogins = null)
storeLastLockTimestamp(userId = userId, lastLockTimestamp = null)
// Do not remove the DeviceKey or PendingAuthRequest on logout, these are persisted
// indefinitely unless the TDE flow explicitly removes them.
@ -502,6 +505,19 @@ class AuthDiskSourceImpl(
)
}
override fun getLastLockTimestamp(userId: String): Instant? {
return getLong(key = LAST_LOCK_TIMESTAMP.appendIdentifier(userId))?.let {
Instant.ofEpochMilli(it)
}
}
override fun storeLastLockTimestamp(userId: String, lastLockTimestamp: Instant?) {
putLong(
key = LAST_LOCK_TIMESTAMP.appendIdentifier(userId),
value = lastLockTimestamp?.toEpochMilli(),
)
}
private fun generateAndStoreUniqueAppId(): String =
UUID
.randomUUID()

View File

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
@ -391,4 +392,14 @@ interface SettingsDiskSource {
* Returns an [Flow] to observe updates to the "ShouldShowGeneratorCoachMark" value.
*/
fun getShouldShowGeneratorCoachMarkFlow(): Flow<Boolean?>
/**
* Stores the given [screenData] as the screen to resume to identified by [userId].
*/
fun storeAppResumeScreen(userId: String, screenData: AppResumeScreenData?)
/**
* Gets the screen data to resume to for the device identified by [userId] or null if no screen
*/
fun getAppResumeScreen(userId: String): AppResumeScreenData?
}

View File

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
@ -41,6 +42,7 @@ private const val COPY_ACTION_COUNT = "copyActionCount"
private const val CREATE_ACTION_COUNT = "createActionCount"
private const val SHOULD_SHOW_ADD_LOGIN_COACH_MARK = "shouldShowAddLoginCoachMark"
private const val SHOULD_SHOW_GENERATOR_COACH_MARK = "shouldShowGeneratorCoachMark"
private const val RESUME_SCREEN = "resumeScreen"
/**
* Primary implementation of [SettingsDiskSource].
@ -185,6 +187,7 @@ class SettingsDiskSourceImpl(
storeClearClipboardFrequencySeconds(userId = userId, frequency = null)
removeWithPrefix(prefix = ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY.appendIdentifier(userId))
storeVaultRegisteredForExport(userId = userId, isRegistered = null)
storeAppResumeScreen(userId = userId, screenData = null)
// The following are intentionally not cleared so they can be
// restored after logging out and back in:
@ -526,6 +529,16 @@ class SettingsDiskSourceImpl(
emit(getShouldShowGeneratorCoachMark())
}
override fun storeAppResumeScreen(userId: String, screenData: AppResumeScreenData?) {
putString(
key = RESUME_SCREEN.appendIdentifier(userId),
value = screenData?.let { json.encodeToString(it) },
)
}
override fun getAppResumeScreen(userId: String): AppResumeScreenData? =
getString(RESUME_SCREEN.appendIdentifier(userId))?.let { json.decodeFromStringOrNull(it) }
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
/**
* Manages the screen from which the app should be resumed after unlock.
*/
interface AppResumeManager {
/**
* Sets the screen from which the app should be resumed after unlock.
*
* @param screenData The screen identifier (e.g., "HomeScreen", "SettingsScreen").
*/
fun setResumeScreen(screenData: AppResumeScreenData)
/**
* Gets the screen from which the app should be resumed after unlock.
*
* @return The screen identifier, or an empty string if not set.
*/
fun getResumeScreen(): AppResumeScreenData?
/**
* Gets the special circumstance associated with the resume screen for the current user.
*
* @return The special circumstance, or null if no special circumstance
* is associated with the resume screen.
*/
fun getResumeSpecialCircumstance(): SpecialCircumstance?
/**
* Clears the saved resume screen for the current user.
*/
fun clearResumeScreen()
}

View File

@ -0,0 +1,74 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import java.time.Clock
private const val UNLOCK_NAVIGATION_TIME_SECONDS: Long = 5 * 60
/**
* Primary implementation of [AppResumeManager].
*/
class AppResumeManagerImpl(
private val settingsDiskSource: SettingsDiskSource,
private val authDiskSource: AuthDiskSource,
private val authRepository: AuthRepository,
private val vaultLockManager: VaultLockManager,
private val clock: Clock,
) : AppResumeManager {
override fun setResumeScreen(screenData: AppResumeScreenData) {
authRepository.activeUserId?.let {
settingsDiskSource.storeAppResumeScreen(
userId = it,
screenData = screenData,
)
}
}
override fun getResumeScreen(): AppResumeScreenData? {
return authRepository.activeUserId?.let { userId ->
settingsDiskSource.getAppResumeScreen(userId)
}
}
override fun getResumeSpecialCircumstance(): SpecialCircumstance? {
val userId = authRepository.activeUserId ?: return null
val timeNowMinus5Min = clock.instant().minusSeconds(UNLOCK_NAVIGATION_TIME_SECONDS)
val lastLockTimestamp = authDiskSource
.getLastLockTimestamp(userId = userId)
?: return null
if (timeNowMinus5Min.isAfter(lastLockTimestamp)) {
settingsDiskSource.storeAppResumeScreen(userId = userId, screenData = null)
return null
}
return when (val resumeScreenData = getResumeScreen()) {
AppResumeScreenData.GeneratorScreen -> SpecialCircumstance.GeneratorShortcut
AppResumeScreenData.SendScreen -> SpecialCircumstance.SendShortcut
is AppResumeScreenData.SearchScreen -> SpecialCircumstance.SearchShortcut(
searchTerm = resumeScreenData.searchTerm,
)
AppResumeScreenData.VerificationCodeScreen -> {
SpecialCircumstance.VerificationCodeShortcut
}
else -> null
}
}
override fun clearResumeScreen() {
val userId = authRepository.activeUserId ?: return
if (vaultLockManager.isVaultUnlocked(userId = userId)) {
settingsDiskSource.storeAppResumeScreen(
userId = userId,
screenData = null,
)
}
}
}

View File

@ -15,6 +15,8 @@ import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterM
import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator
import com.x8bit.bitwarden.data.platform.datasource.network.service.EventService
import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.AppResumeManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AppStateManager
import com.x8bit.bitwarden.data.platform.manager.AppStateManagerImpl
import com.x8bit.bitwarden.data.platform.manager.AssetManager
@ -66,6 +68,7 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
@ -337,4 +340,22 @@ object PlatformManagerModule {
fun provideKeyManager(
@ApplicationContext context: Context,
): KeyManager = KeyManagerImpl(context = context)
@Provides
@Singleton
fun provideAppResumeManager(
settingsDiskSource: SettingsDiskSource,
authDiskSource: AuthDiskSource,
authRepository: AuthRepository,
vaultLockManager: VaultLockManager,
clock: Clock,
): AppResumeManager {
return AppResumeManagerImpl(
settingsDiskSource = settingsDiskSource,
authDiskSource = authDiskSource,
authRepository = authRepository,
vaultLockManager = vaultLockManager,
clock = clock,
)
}
}

View File

@ -0,0 +1,34 @@
package com.x8bit.bitwarden.data.platform.manager.model
import kotlinx.serialization.Serializable
/**
* Data class representing the Screen Data for app resume.
*/
@Serializable
sealed class AppResumeScreenData {
/**
* Data object representing the Generator screen for app resume.
*/
@Serializable
data object GeneratorScreen : AppResumeScreenData()
/**
* Data object representing the Send screen for app resume.
*/
@Serializable
data object SendScreen : AppResumeScreenData()
/**
* Data class representing the Search screen for app resume.
*/
@Serializable
data class SearchScreen(val searchTerm: String) : AppResumeScreenData()
/**
* Data object representing the Verification Code screen for app resume.
*/
@Serializable
data object VerificationCodeScreen : AppResumeScreenData()
}

View File

@ -104,6 +104,24 @@ sealed class SpecialCircumstance : Parcelable {
@Parcelize
data object AccountSecurityShortcut : SpecialCircumstance()
/**
* Deeplink to the Send.
*/
@Parcelize
data object SendShortcut : SpecialCircumstance()
/**
* Deeplink to the Search.
*/
@Parcelize
data class SearchShortcut(val searchTerm: String) : SpecialCircumstance()
/**
* Deeplink to the Verification Code.
*/
@Parcelize
data object VerificationCodeShortcut : SpecialCircumstance()
/**
* A subset of [SpecialCircumstance] that are only relevant in a pre-login state and should be
* cleared after a successful login.

View File

@ -0,0 +1,80 @@
package com.x8bit.bitwarden.data.platform.manager.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.Lifecycle
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager
/**
* Manages the state of the screen to resume to after the app is unlocked.
*/
interface AppResumeStateManager {
/**
* The current state of the screen to resume to.
* It will be `null` if there is no screen to resume to.
*/
val appResumeState: State<AppResumeScreenData?>
/**
* Updates the screen data to resume to.
*
* @param data The [AppResumeScreenData] for the screen to resume to, or `null` if there is no
* screen to resume to.
*/
fun updateScreenData(data: AppResumeScreenData?)
}
/**
* Primary implementation of [AppResumeStateManager].
*/
class AppResumeStateManagerImpl : AppResumeStateManager {
private val mutableAppResumeState = mutableStateOf<AppResumeScreenData?>(null)
override val appResumeState: State<AppResumeScreenData?> = mutableAppResumeState
override fun updateScreenData(data: AppResumeScreenData?) {
mutableAppResumeState.value = data
}
}
/**
* Consumer
*
* onDataUpdate (call in central location: MainViewModel -> updates the data source through action
* handling.
*/
@Composable
fun ObserveScreenDataEffect(onDataUpdate: (AppResumeScreenData?) -> Unit) {
val appResumeStateManager = LocalAppResumeStateManager.current
LaunchedEffect(appResumeStateManager.appResumeState.value) {
onDataUpdate(appResumeStateManager.appResumeState.value)
}
}
/**
* Producer
*
* Add to screen where needed and pass in the necessary instance of [AppResumeScreenData]
*/
@Composable
fun RegisterScreenDataOnLifecycleEffect(
appResumeStateManager: AppResumeStateManager = LocalAppResumeStateManager.current,
appResumeStateProvider: () -> AppResumeScreenData,
) {
LivecycleEventEffect { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
appResumeStateManager.updateScreenData(data = appResumeStateProvider())
}
Lifecycle.Event.ON_STOP -> {
appResumeStateManager.updateScreenData(data = null)
}
else -> Unit
}
}
}

View File

@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.vault.manager
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.crypto.Kdf
import com.bitwarden.sdk.AuthClient
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult

View File

@ -281,6 +281,10 @@ class VaultLockManagerImpl(
)
if (!wasVaultLocked) {
mutableVaultStateEventSharedFlow.tryEmit(VaultStateEvent.Locked(userId = userId))
authDiskSource.storeLastLockTimestamp(
userId = userId,
lastLockTimestamp = clock.instant(),
)
}
}

View File

@ -11,6 +11,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@ -52,6 +53,7 @@ class VaultUnlockViewModel @Inject constructor(
private val biometricsEncryptionManager: BiometricsEncryptionManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val fido2CredentialManager: Fido2CredentialManager,
private val appResumeManager: AppResumeManager,
environmentRepo: EnvironmentRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<VaultUnlockState, VaultUnlockEvent, VaultUnlockAction>(
@ -71,7 +73,9 @@ class VaultUnlockViewModel @Inject constructor(
// There is no valid way to unlock this app.
authRepository.logout()
}
val specialCircumstance = specialCircumstanceManager.specialCircumstance
val showAccountMenu =
VaultUnlockArgs(savedStateHandle).unlockType == UnlockType.STANDARD &&
(specialCircumstance !is SpecialCircumstance.Fido2GetCredentials &&
@ -340,6 +344,11 @@ class VaultUnlockViewModel @Inject constructor(
}
VaultUnlockResult.Success -> {
if (specialCircumstanceManager.specialCircumstance == null) {
specialCircumstanceManager.specialCircumstance =
appResumeManager.getResumeSpecialCircumstance()
}
mutableStateFlow.update { it.copy(dialog = null) }
// Don't do anything, we'll navigate to the right place.
}

View File

@ -10,6 +10,8 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManagerImpl
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager
import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManagerImpl
@ -49,6 +51,7 @@ fun LocalManagerProvider(
LocalNfcManager provides NfcManagerImpl(activity),
LocalFido2CompletionManager provides fido2CompletionManager,
LocalAppReviewManager provides AppReviewManagerImpl(activity),
LocalAppResumeStateManager provides AppResumeStateManagerImpl(),
) {
content()
}
@ -103,3 +106,7 @@ val LocalFido2CompletionManager: ProvidableCompositionLocal<Fido2CompletionManag
val LocalAppReviewManager: ProvidableCompositionLocal<AppReviewManager> = compositionLocalOf {
error("CompositionLocal AppReviewManager not present")
}
val LocalAppResumeStateManager = compositionLocalOf<AppResumeStateManager> {
error("CompositionLocal AppResumeStateManager not present")
}

View File

@ -104,7 +104,9 @@ class RootNavViewModel @Inject constructor(
}
userState.activeAccount.isVaultUnlocked &&
authRepository.checkUserNeedsNewDeviceTwoFactorNotice() -> RootNavState.NewDeviceTwoFactorNotice(userState.activeAccount.email)
authRepository.checkUserNeedsNewDeviceTwoFactorNotice() -> RootNavState.NewDeviceTwoFactorNotice(
userState.activeAccount.email,
)
userState.activeAccount.isVaultUnlocked -> {
when (specialCircumstance) {
@ -157,6 +159,9 @@ class RootNavViewModel @Inject constructor(
SpecialCircumstance.AccountSecurityShortcut,
SpecialCircumstance.GeneratorShortcut,
SpecialCircumstance.VaultShortcut,
SpecialCircumstance.SendShortcut,
is SpecialCircumstance.SearchShortcut,
SpecialCircumstance.VerificationCodeShortcut,
null,
-> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId)

View File

@ -20,6 +20,9 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.manager.util.RegisterScreenDataOnLifecycleEffect
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenSearchTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
@ -29,6 +32,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.feature.search.handlers.SearchHandlers
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
@ -48,10 +52,20 @@ fun SearchScreen(
onNavigateToViewCipher: (cipherId: String) -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
viewModel: SearchViewModel = hiltViewModel(),
appResumeStateManager: AppResumeStateManager = LocalAppResumeStateManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val searchHandlers = remember(viewModel) { SearchHandlers.create(viewModel) }
val context = LocalContext.current
RegisterScreenDataOnLifecycleEffect(
appResumeStateManager = appResumeStateManager,
) {
AppResumeScreenData.SearchScreen(
searchTerm = state.searchTerm,
)
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
SearchEvent.NavigateBack -> onNavigateBack()

View File

@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.util.toAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.manager.util.toTotpDataOrNull
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
@ -86,9 +87,15 @@ class SearchViewModel @Inject constructor(
val searchType = SearchArgs(savedStateHandle).type
val userState = requireNotNull(authRepo.userStateFlow.value)
val specialCircumstance = specialCircumstanceManager.specialCircumstance
val searchTerm = (specialCircumstance as? SpecialCircumstance.SearchShortcut)
?.searchTerm
?.also {
specialCircumstanceManager.specialCircumstance = null
}
.orEmpty()
SearchState(
searchTerm = "",
searchTerm = searchTerm,
searchType = searchType.toSearchTypeData(),
viewState = SearchState.ViewState.Loading,
dialogState = null,

View File

@ -91,7 +91,9 @@ fun VaultUnlockedNavBarScreen(
navigateToVaultGraph(navOptions)
}
VaultUnlockedNavBarEvent.NavigateToSendScreen -> {
VaultUnlockedNavBarEvent.Shortcut.NavigateToSendScreen,
VaultUnlockedNavBarEvent.NavigateToSendScreen,
-> {
navigateToSendGraph(navOptions)
}

View File

@ -69,6 +69,29 @@ class VaultUnlockedNavBarViewModel @Inject constructor(
sendEvent(VaultUnlockedNavBarEvent.Shortcut.NavigateToSettingsScreen)
}
SpecialCircumstance.SendShortcut -> {
sendEvent(VaultUnlockedNavBarEvent.Shortcut.NavigateToSendScreen)
specialCircumstancesManager.specialCircumstance = null
}
is SpecialCircumstance.SearchShortcut -> {
sendEvent(
VaultUnlockedNavBarEvent.Shortcut.NavigateToVaultScreen(
labelRes = state.vaultNavBarLabelRes,
contentDescRes = state.vaultNavBarContentDescriptionRes,
),
)
}
is SpecialCircumstance.VerificationCodeShortcut -> {
sendEvent(
VaultUnlockedNavBarEvent.Shortcut.NavigateToVaultScreen(
labelRes = state.vaultNavBarLabelRes,
contentDescRes = state.vaultNavBarContentDescriptionRes,
),
)
}
else -> Unit
}
}
@ -294,5 +317,12 @@ sealed class VaultUnlockedNavBarEvent {
data object NavigateToSettingsScreen : Shortcut() {
override val tab: VaultUnlockedNavBarTab = VaultUnlockedNavBarTab.Settings()
}
/**
* Navigate to the Send Screen.
*/
data object NavigateToSendScreen : Shortcut() {
override val tab: VaultUnlockedNavBarTab = VaultUnlockedNavBarTab.Send
}
}
}

View File

@ -38,6 +38,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.manager.util.RegisterScreenDataOnLifecycleEffect
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.LivecycleEventEffect
import com.x8bit.bitwarden.ui.platform.base.util.scrolledContainerBottomDivider
@ -76,6 +79,7 @@ import com.x8bit.bitwarden.ui.platform.components.stepper.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
import com.x8bit.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
@ -117,6 +121,7 @@ fun GeneratorScreen(
onNavigateBack: () -> Unit,
onDimNavBarRequest: (Boolean) -> Unit,
intentManager: IntentManager = LocalIntentManager.current,
appResumeStateManager: AppResumeStateManager = LocalAppResumeStateManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val snackbarHostState = rememberBitwardenSnackbarHostState()
@ -129,6 +134,11 @@ fun GeneratorScreen(
else -> Unit
}
}
RegisterScreenDataOnLifecycleEffect(
appResumeStateManager = appResumeStateManager,
) {
AppResumeScreenData.GeneratorScreen
}
val lazyListState = rememberLazyListState()
val coachMarkState = rememberLazyListCoachMarkState(

View File

@ -21,6 +21,9 @@ import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.manager.util.RegisterScreenDataOnLifecycleEffect
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenMediumTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.action.BitwardenOverflowActionItem
@ -34,6 +37,7 @@ import com.x8bit.bitwarden.ui.platform.components.fab.BitwardenFloatingActionBut
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPullToRefreshState
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
@ -54,6 +58,7 @@ fun SendScreen(
onNavigateToSearchSend: (searchType: SearchType.Sends) -> Unit,
viewModel: SendViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
appResumeStateManager: AppResumeStateManager = LocalAppResumeStateManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
@ -65,6 +70,12 @@ fun SendScreen(
},
)
RegisterScreenDataOnLifecycleEffect(
appResumeStateManager = appResumeStateManager,
) {
AppResumeScreenData.SendScreen
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is SendEvent.NavigateToSearch -> onNavigateToSearchSend(SearchType.Sends.All)

View File

@ -12,10 +12,12 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
@ -77,6 +79,7 @@ class VaultViewModel @Inject constructor(
private val snackbarRelayManager: SnackbarRelayManager,
private val reviewPromptManager: ReviewPromptManager,
private val featureFlagManager: FeatureFlagManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
@ -204,6 +207,22 @@ class VaultViewModel @Inject constructor(
}
private fun handleLifecycleResumed() {
when (specialCircumstanceManager.specialCircumstance) {
is SpecialCircumstance.SearchShortcut -> {
sendEvent(VaultEvent.NavigateToVaultSearchScreen)
// not clearing SpecialCircumstance as it contains necessary data
return
}
is SpecialCircumstance.VerificationCodeShortcut -> {
sendEvent(VaultEvent.NavigateToVerificationCodeScreen)
specialCircumstanceManager.specialCircumstance = null
return
}
else -> Unit
}
val shouldShowPrompt = reviewPromptManager.shouldPromptForAppReview() &&
featureFlagManager.getFeatureFlag(FlagKey.AppReviewPrompt)
if (shouldShowPrompt) {

View File

@ -21,6 +21,9 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.manager.util.RegisterScreenDataOnLifecycleEffect
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.base.util.toListItemCardStyle
@ -35,6 +38,7 @@ import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPullToRefreshState
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.handlers.VerificationCodeHandlers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -51,6 +55,7 @@ fun VerificationCodeScreen(
onNavigateBack: () -> Unit,
onNavigateToSearch: () -> Unit,
onNavigateToVaultItemScreen: (String) -> Unit,
appResumeStateManager: AppResumeStateManager = LocalAppResumeStateManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val verificationCodeHandler = remember(viewModel) {
@ -65,6 +70,12 @@ fun VerificationCodeScreen(
},
)
RegisterScreenDataOnLifecycleEffect(
appResumeStateManager = appResumeStateManager,
) {
AppResumeScreenData.VerificationCodeScreen
}
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is VerificationCodeEvent.NavigateBack -> onNavigateBack()

View File

@ -21,8 +21,8 @@ import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialReques
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CreateCredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CreateCredentialRequestOrNull
@ -34,9 +34,11 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData
@ -130,6 +132,11 @@ class MainViewModelTest : BaseViewModelTest() {
}
private val savedStateHandle = SavedStateHandle()
private val appResumeManager: AppResumeManager = mockk {
every { setResumeScreen(any()) } just runs
every { clearResumeScreen() } just runs
}
@BeforeEach
fun setup() {
mockkStatic(
@ -1061,6 +1068,28 @@ class MainViewModelTest : BaseViewModelTest() {
verify { authRepository.switchAccount(userId) }
}
@Suppress("MaxLineLength")
@Test
fun `on ResumeScreenDataReceived with null value, should call AppResumeManager clearResumeScreen`() {
val viewModel = createViewModel()
viewModel.trySendAction(
MainAction.ResumeScreenDataReceived(screenResumeData = null),
)
verify { appResumeManager.clearResumeScreen() }
}
@Suppress("MaxLineLength")
@Test
fun `on ResumeScreenDataReceived with data value, should call AppResumeManager setResumeScreen`() {
val viewModel = createViewModel()
viewModel.trySendAction(
MainAction.ResumeScreenDataReceived(screenResumeData = AppResumeScreenData.GeneratorScreen),
)
verify { appResumeManager.setResumeScreen(AppResumeScreenData.GeneratorScreen) }
}
private fun createViewModel(
initialSpecialCircumstance: SpecialCircumstance? = null,
) = MainViewModel(
@ -1079,6 +1108,7 @@ class MainViewModelTest : BaseViewModelTest() {
savedStateHandle = savedStateHandle.apply {
set(SPECIAL_CIRCUMSTANCE_KEY, initialSpecialCircumstance)
},
appResumeManager = appResumeManager,
)
}

View File

@ -27,13 +27,13 @@ import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.encodeToJsonElement
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.time.Instant
import java.time.ZonedDateTime
@Suppress("LargeClass")
@ -1335,6 +1335,62 @@ class AuthDiskSourceTest {
actual,
)
}
@Test
fun `getLastLockTimestamp should pull from SharedPreferences`() {
val storeKey = "bwPreferencesStorage:lastLockTimestamp"
val mockUserId = "mockUserId"
val expectedState = Instant.parse("2025-01-13T12:00:00Z")
fakeSharedPreferences.edit {
putLong(
"${storeKey}_$mockUserId",
expectedState.toEpochMilli(),
)
}
val actual = authDiskSource.getLastLockTimestamp(userId = mockUserId)
assertEquals(
expectedState,
actual,
)
}
@Test
fun `getLastLockTimestamp should pull null from SharedPreferences if there is no data`() {
val mockUserId = "mockUserId"
val expectedState = null
val actual = authDiskSource.getLastLockTimestamp(userId = mockUserId)
assertEquals(
expectedState,
actual,
)
}
@Test
fun `setLastLockTimestamp should update SharedPreferences`() {
val mockUserId = "mockUserId"
val expectedState = Instant.parse("2025-01-13T12:00:00Z")
authDiskSource.storeLastLockTimestamp(
userId = mockUserId,
expectedState,
)
val actual = authDiskSource.getLastLockTimestamp(userId = mockUserId)
assertEquals(
expectedState,
actual,
)
}
@Test
fun `setLastLockTimestamp should clear SharedPreferences when null is passed`() {
val mockUserId = "mockUserId"
val expectedState = null
authDiskSource.storeLastLockTimestamp(
userId = mockUserId,
expectedState,
)
val actual = authDiskSource.getLastLockTimestamp(userId = mockUserId)
assertNull(actual)
}
}
private const val USER_STATE_JSON = """

View File

@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import org.junit.Assert.assertEquals
import java.time.Instant
class FakeAuthDiskSource : AuthDiskSource {
@ -64,6 +65,7 @@ class FakeAuthDiskSource : AuthDiskSource {
private val storedOnboardingStatus = mutableMapOf<String, OnboardingStatus?>()
private val storedShowImportLogins = mutableMapOf<String, Boolean?>()
private val storedNewDeviceNoticeState = mutableMapOf<String, NewDeviceNoticeState?>()
private val storedLastLockTimestampState = mutableMapOf<String, Instant?>()
override var userState: UserStateJson? = null
set(value) {
@ -314,13 +316,21 @@ class FakeAuthDiskSource : AuthDiskSource {
return storedNewDeviceNoticeState[userId] ?: NewDeviceNoticeState(
displayStatus = NewDeviceNoticeDisplayStatus.HAS_NOT_SEEN,
lastSeenDate = null,
)
)
}
override fun storeNewDeviceNoticeState(userId: String, newState: NewDeviceNoticeState?) {
storedNewDeviceNoticeState[userId] = newState
}
override fun getLastLockTimestamp(userId: String): Instant? {
return storedLastLockTimestampState[userId]
}
override fun storeLastLockTimestamp(userId: String, lastLockTimestamp: Instant?) {
storedLastLockTimestampState[userId] = lastLockTimestamp
}
/**
* Assert the the [isTdeLoginComplete] was stored successfully using the [userId].
*/
@ -471,6 +481,13 @@ class FakeAuthDiskSource : AuthDiskSource {
assertEquals(policies, storedPolicies[userId])
}
/**
* Assert that the [lastLockTimestamp] was stored successfully using the [userId].
*/
fun assertLastLockTimestamp(userId: String, expectedValue: Instant?) {
assertEquals(expectedValue, storedLastLockTimestampState[userId])
}
//region Private helper functions
private fun getMutableShouldUseKeyConnectorFlow(

View File

@ -4,12 +4,15 @@ import androidx.core.content.edit
import app.cash.turbine.test
import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.repository.model.ClearClipboardFrequency
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
@ -1314,4 +1317,52 @@ class SettingsDiskSourceTest {
assertTrue(awaitItem() ?: false)
}
}
@Test
fun `getAppResumeScreen should pull from SharedPreferences`() {
val mockUserId = "mockUserId"
val resumeScreenKey = "bwPreferencesStorage:resumeScreen_$mockUserId"
val expectedData = AppResumeScreenData.GeneratorScreen
fakeSharedPreferences.edit {
putString(
resumeScreenKey,
json.encodeToString<AppResumeScreenData>(expectedData),
)
}
assertEquals(expectedData, settingsDiskSource.getAppResumeScreen(mockUserId))
}
@Test
fun `storeAppResumeScreen should update SharedPreferences`() {
val mockUserId = "mockUserId"
val resumeScreenKey = "bwPreferencesStorage:resumeScreen_$mockUserId"
val expectedData = AppResumeScreenData.GeneratorScreen
settingsDiskSource.storeAppResumeScreen(mockUserId, expectedData)
assertEquals(
expectedData,
fakeSharedPreferences.getString(resumeScreenKey, "")?.let {
Json.decodeFromStringOrNull<AppResumeScreenData>(it)
},
)
}
@Test
fun `storeAppResumeScreen should save null when passed`() {
val mockUserId = "mockUserId"
val resumeScreenKey = "bwPreferencesStorage:resumeScreen_$mockUserId"
val expectedData = AppResumeScreenData.GeneratorScreen
settingsDiskSource.storeAppResumeScreen(mockUserId, expectedData)
assertEquals(
expectedData,
fakeSharedPreferences.getString(resumeScreenKey, "")?.let {
Json.decodeFromStringOrNull<AppResumeScreenData>(it)
},
)
settingsDiskSource.storeAppResumeScreen(mockUserId, null)
assertNull(
fakeSharedPreferences.getString(resumeScreenKey, "")?.let {
Json.decodeFromStringOrNull<AppResumeScreenData>(it)
},
)
}
}

View File

@ -1,14 +1,17 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.util
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.serialization.json.Json
import java.time.Instant
/**
@ -67,6 +70,7 @@ class FakeSettingsDiskSource : SettingsDiskSource {
private val storedScreenCaptureAllowed = mutableMapOf<String, Boolean?>()
private var storedSystemBiometricIntegritySource: String? = null
private val storedAccountBiometricIntegrityValidity = mutableMapOf<String, Boolean?>()
private val storedAppResumeScreenData = mutableMapOf<String, String?>()
private val userSignIns = mutableMapOf<String, Boolean>()
private val userShowAutoFillBadge = mutableMapOf<String, Boolean?>()
private val userShowUnlockBadge = mutableMapOf<String, Boolean?>()
@ -424,6 +428,14 @@ class FakeSettingsDiskSource : SettingsDiskSource {
emit(hasSeenGeneratorCoachMark)
}
override fun storeAppResumeScreen(userId: String, screenData: AppResumeScreenData?) {
storedAppResumeScreenData[userId] = screenData.let { Json.encodeToString(it) }
}
override fun getAppResumeScreen(userId: String): AppResumeScreenData? {
return storedAppResumeScreenData[userId]?.let { Json.decodeFromStringOrNull(it) }
}
//region Private helper functions
private fun getMutableScreenCaptureAllowedFlow(userId: String): MutableSharedFlow<Boolean?> {
return mutableScreenCaptureAllowedFlowMap.getOrPut(userId) {

View File

@ -0,0 +1,171 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.util.FakeSettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.vault.manager.VaultLockManager
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
class AppResumeManagerTest {
private val fakeSettingsDiskSource: SettingsDiskSource = FakeSettingsDiskSource()
private val authRepository = mockk<AuthRepository> {
every { activeUserId } returns USER_ID
}
private val vaultLockManager: VaultLockManager = mockk {
every { isVaultUnlocked(USER_ID) } returns true
}
private val fixedClock: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val fakeAuthDiskSource = FakeAuthDiskSource()
private val appResumeManager = AppResumeManagerImpl(
settingsDiskSource = fakeSettingsDiskSource,
authDiskSource = fakeAuthDiskSource,
authRepository = authRepository,
vaultLockManager = vaultLockManager,
clock = fixedClock,
)
@Test
fun `setResumeScreen should update the app resume screen in the settings disk source`() =
runTest {
val expectedValue = AppResumeScreenData.SendScreen
appResumeManager.setResumeScreen(expectedValue)
val actualValue = fakeSettingsDiskSource.getAppResumeScreen(USER_ID)
assertEquals(expectedValue, actualValue)
}
@Test
fun `getResumeScreen should return null when there is no app resume screen saved`() =
runTest {
val actualValue = appResumeManager.getResumeScreen()
assertNull(actualValue)
}
@Test
fun `getResumeScreen should return the saved AppResumeScreen`() =
runTest {
val expectedValue = AppResumeScreenData.GeneratorScreen
fakeSettingsDiskSource.storeAppResumeScreen(
userId = USER_ID,
screenData = expectedValue,
)
val actualValue = appResumeManager.getResumeScreen()
assertEquals(expectedValue, actualValue)
}
@Test
fun `clearResumeScreen should clear the app resume screen in the settings disk source`() =
runTest {
fakeSettingsDiskSource.storeAppResumeScreen(
userId = USER_ID,
screenData = AppResumeScreenData.GeneratorScreen,
)
appResumeManager.clearResumeScreen()
val actualValue = fakeSettingsDiskSource.getAppResumeScreen(USER_ID)
assertNull(actualValue)
}
@Suppress("MaxLineLength")
@Test
fun `getResumeSpecialCircumstance should return GeneratorShortcut when the resume screen is GeneratorScreen`() =
runTest {
fakeSettingsDiskSource.storeAppResumeScreen(
userId = USER_ID,
screenData = AppResumeScreenData.GeneratorScreen,
)
fakeAuthDiskSource.storeLastLockTimestamp(USER_ID, fixedClock.instant())
val expectedValue = SpecialCircumstance.GeneratorShortcut
val actualValue = appResumeManager.getResumeSpecialCircumstance()
assertEquals(expectedValue, actualValue)
}
@Suppress("MaxLineLength")
@Test
fun `getResumeSpecialCircumstance should return SendShortcut when the resume screen is SendScreen`() =
runTest {
fakeSettingsDiskSource.storeAppResumeScreen(
userId = USER_ID,
screenData = AppResumeScreenData.SendScreen,
)
fakeAuthDiskSource.storeLastLockTimestamp(USER_ID, fixedClock.instant())
val expectedValue = SpecialCircumstance.SendShortcut
val actualValue = appResumeManager.getResumeSpecialCircumstance()
assertEquals(expectedValue, actualValue)
}
@Suppress("MaxLineLength")
@Test
fun `getResumeSpecialCircumstance should return VerificationCodeShortcut when the resume screen is VerificationCodeScreen`() =
runTest {
fakeSettingsDiskSource.storeAppResumeScreen(
userId = USER_ID,
screenData = AppResumeScreenData.VerificationCodeScreen,
)
fakeAuthDiskSource.storeLastLockTimestamp(USER_ID, fixedClock.instant())
val expectedValue = SpecialCircumstance.VerificationCodeShortcut
val actualValue = appResumeManager.getResumeSpecialCircumstance()
assertEquals(expectedValue, actualValue)
}
@Suppress("MaxLineLength")
@Test
fun `getResumeSpecialCircumstance should return SearchShortcut when the resume screen is SearchScreen`() =
runTest {
fakeSettingsDiskSource.storeAppResumeScreen(
userId = USER_ID,
screenData = AppResumeScreenData.SearchScreen("test"),
)
fakeAuthDiskSource.storeLastLockTimestamp(USER_ID, fixedClock.instant())
val expectedValue = SpecialCircumstance.SearchShortcut("test")
val actualValue = appResumeManager.getResumeSpecialCircumstance()
assertEquals(expectedValue, actualValue)
}
@Suppress("MaxLineLength")
@Test
fun `getResumeSpecialCircumstance should should clear app resume screen if have passed 5 minutes`() {
val delayedAuthDiskSource: AuthDiskSource = mockk {
every { getLastLockTimestamp(any()) } returns fixedClock.instant()
.minusSeconds(5 * 60 + 1)
}
val delayedAppResumeManager = AppResumeManagerImpl(
settingsDiskSource = fakeSettingsDiskSource,
authDiskSource = delayedAuthDiskSource,
authRepository = authRepository,
vaultLockManager = vaultLockManager,
clock = fixedClock,
)
fakeSettingsDiskSource.storeAppResumeScreen(
userId = USER_ID,
screenData = AppResumeScreenData.GeneratorScreen,
)
val actualValue = delayedAppResumeManager.getResumeSpecialCircumstance()
assertNull(actualValue)
val actualSettingsValue = fakeSettingsDiskSource.getAppResumeScreen(
userId = USER_ID,
)
assertNull(actualSettingsValue)
}
}
private const val USER_ID = "user_id"

View File

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import org.junit.Assert
import org.junit.Test
class AppResumeStateManagerTest {
private val appStateManager = AppResumeStateManagerImpl()
@Test
fun `AppResumeStateManagerImpl should update and retrieve screen data`() {
val screenData = AppResumeScreenData.GeneratorScreen
appStateManager.updateScreenData(screenData)
Assert.assertEquals(screenData, appStateManager.appResumeState.value)
}
@Test
fun `AppResumeStateManagerImpl should retrieve null if not set`() {
Assert.assertEquals(null, appStateManager.appResumeState.value)
}
}

View File

@ -167,6 +167,10 @@ class VaultLockManagerTest {
vaultLockManager.vaultStateEventFlow.test {
vaultLockManager.lockVault(userId = USER_ID)
assertEquals(VaultStateEvent.Locked(userId = USER_ID), awaitItem())
fakeAuthDiskSource.assertLastLockTimestamp(
userId = USER_ID,
FIXED_CLOCK.instant(),
)
}
}

View File

@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.createMockFido2GetCredentialsRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
@ -80,8 +81,14 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
every { isUserVerified } returns true
every { isUserVerified = any() } just runs
}
private val specialCircumstanceManager: SpecialCircumstanceManager = mockk {
every { specialCircumstance } returns null
every { specialCircumstance = any() } answers { }
}
private val appResumeManager: AppResumeManager = mockk {
every { getResumeSpecialCircumstance() } returns null
}
@Test
@ -1248,6 +1255,7 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
biometricsEncryptionManager = biometricsEncryptionManager,
fido2CredentialManager = fido2CredentialManager,
specialCircumstanceManager = specialCircumstanceManager,
appResumeManager = appResumeManager,
)
}

View File

@ -762,34 +762,7 @@ class RootNavViewModelTest : BaseViewModelTest() {
val fido2GetCredentialsRequest = createMockFido2GetCredentialsRequest(number = 1)
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2GetCredentials(fido2GetCredentialsRequest)
mutableUserStateFlow.tryEmit(
UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarHexColor",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(
showImportLoginsCard = true,
),
),
),
),
)
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlockedForFido2GetCredentials(
@ -800,6 +773,45 @@ class RootNavViewModelTest : BaseViewModelTest() {
)
}
@Suppress("MaxLineLength")
@Test
fun `when the active user has an unlocked vault but there is an SendShortcut special circumstance the nav state should be VaultUnlocked`() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.SendShortcut
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlocked(activeUserId = "activeUserId"),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `when the active user has an unlocked vault but there is an VerificationCodeShortcut special circumstance the nav state should be VaultUnlocked`() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.VerificationCodeShortcut
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlocked(activeUserId = "activeUserId"),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `when the active user has an unlocked vault but there is an SearchShortcut special circumstance the nav state should be VaultUnlocked`() {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.SearchShortcut("")
mutableUserStateFlow.tryEmit(MOCK_VAULT_UNLOCKED_USER_STATE)
val viewModel = createViewModel()
assertEquals(
RootNavState.VaultUnlocked(activeUserId = "activeUserId"),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `when there are no accounts but there is a CompleteRegistration special circumstance the nav state should be CompleteRegistration`() {
@ -1386,3 +1398,28 @@ private val FIXED_CLOCK: Clock = Clock.fixed(
)
private const val ACCESS_TOKEN: String = "access_token"
private val MOCK_VAULT_UNLOCKED_USER_STATE = UserState(
activeUserId = "activeUserId",
accounts = listOf(
UserState.Account(
userId = "activeUserId",
name = "name",
email = "email",
avatarColorHex = "avatarColorHex",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
firstTimeState = FirstTimeState(false),
onboardingStatus = OnboardingStatus.COMPLETE,
),
),
)

View File

@ -18,6 +18,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -55,6 +56,8 @@ class SearchScreenTest : BaseComposeTest() {
every { launchUri(any()) } just runs
}
private val appResumeStateManager: AppResumeStateManager = mockk(relaxed = true)
private var onNavigateBackCalled = false
private var onNavigateToEditSendId: String? = null
private var onNavigateToEditCipherId: String? = null
@ -70,6 +73,7 @@ class SearchScreenTest : BaseComposeTest() {
onNavigateToEditSend = { onNavigateToEditSendId = it },
onNavigateToEditCipher = { onNavigateToEditCipherId = it },
onNavigateToViewCipher = { onNavigateToViewCipherId = it },
appResumeStateManager = appResumeStateManager,
)
}
}

View File

@ -248,6 +248,75 @@ class VaultUnlockedNavBarViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength")
@Test
fun `on init with SendShortcut special circumstance should navigate to the send screen with shortcut event`() =
runTest {
every {
specialCircumstancesManager.specialCircumstance
} returns SpecialCircumstance.SendShortcut
val viewModel = createViewModel()
viewModel.eventFlow.test {
assertEquals(
VaultUnlockedNavBarEvent.Shortcut.NavigateToSendScreen,
awaitItem(),
)
}
verify(exactly = 1) {
specialCircumstancesManager.specialCircumstance
specialCircumstancesManager.specialCircumstance = null
}
}
@Suppress("MaxLineLength")
@Test
fun `on init with VerificationCodeShortcut special circumstance should navigate to the Vault screen with shortcut event`() =
runTest {
every {
specialCircumstancesManager.specialCircumstance
} returns SpecialCircumstance.VerificationCodeShortcut
val viewModel = createViewModel()
viewModel.eventFlow.test {
assertEquals(
VaultUnlockedNavBarEvent.Shortcut.NavigateToVaultScreen(
labelRes = R.string.my_vault,
contentDescRes = R.string.my_vault,
),
awaitItem(),
)
}
verify(exactly = 1) {
specialCircumstancesManager.specialCircumstance
}
}
@Suppress("MaxLineLength")
@Test
fun `on init with SearchShortcut special circumstance should navigate to the Vault screen with shortcut event`() =
runTest {
every {
specialCircumstancesManager.specialCircumstance
} returns SpecialCircumstance.SearchShortcut("")
val viewModel = createViewModel()
viewModel.eventFlow.test {
assertEquals(
VaultUnlockedNavBarEvent.Shortcut.NavigateToVaultScreen(
labelRes = R.string.my_vault,
contentDescRes = R.string.my_vault,
),
awaitItem(),
)
}
verify(exactly = 1) {
specialCircumstancesManager.specialCircumstance
}
}
private fun createViewModel() =
VaultUnlockedNavBarViewModel(

View File

@ -29,6 +29,7 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight
import androidx.compose.ui.text.AnnotatedString
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -60,16 +61,20 @@ class GeneratorScreenTest : BaseComposeTest() {
private val intentManager: IntentManager = mockk {
every { launchUri(any()) } just runs
}
private val appResumeStateManager: AppResumeStateManager = mockk(relaxed = true)
@Before
fun setup() {
composeTestRule.setContent {
GeneratorScreen(
viewModel = viewModel,
onNavigateToPasswordHistory = { onNavigateToPasswordHistoryScreenCalled = true },
onNavigateToPasswordHistory = {
onNavigateToPasswordHistoryScreenCalled = true
},
onNavigateBack = {},
onDimNavBarRequest = { onDimNavBarRequest = it },
intentManager = intentManager,
appResumeStateManager = appResumeStateManager,
)
}
}

View File

@ -22,6 +22,7 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
@ -60,6 +61,7 @@ class SendScreenTest : BaseComposeTest() {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
private val appResumeStateManager: AppResumeStateManager = mockk(relaxed = true)
@Before
fun setUp() {
@ -72,6 +74,7 @@ class SendScreenTest : BaseComposeTest() {
onNavigateToSendTextList = { onNavigateToSendTextListCalled = true },
onNavigateToSearchSend = { onNavigateToSendSearchCalled = true },
intentManager = intentManager,
appResumeStateManager = appResumeStateManager,
)
}
}

View File

@ -13,11 +13,13 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ReviewPromptManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEvent
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
@ -145,6 +147,11 @@ class VaultViewModelTest : BaseViewModelTest() {
} returns mutableSshKeyVaultItemsEnabledFlow.value
}
private val reviewPromptManager: ReviewPromptManager = mockk()
private val mockAuthRepository = mockk<AuthRepository>(relaxed = true)
private val specialCircumstanceManager: SpecialCircumstanceManager = mockk {
every { specialCircumstance } returns null
}
@Test
fun `initial state should be correct and should trigger a syncIfNecessary call`() {
@ -1841,6 +1848,41 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
@Suppress("MaxLineLength")
fun `init should send NavigateToVerificationCodeScreen when special circumstance is VerificationCodeShortcut`() =
runTest {
every {
specialCircumstanceManager.specialCircumstance
} returns SpecialCircumstance.VerificationCodeShortcut
every { specialCircumstanceManager.specialCircumstance = null } just runs
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.LifecycleResumed)
assertEquals(
VaultEvent.NavigateToVerificationCodeScreen, awaitItem(),
)
}
verify { specialCircumstanceManager.specialCircumstance = null }
}
@Test
@Suppress("MaxLineLength")
fun `init should send NavigateToVaultSearchScreen when special circumstance is SearchShortcut`() =
runTest {
every {
specialCircumstanceManager.specialCircumstance
} returns SpecialCircumstance.SearchShortcut("")
every { specialCircumstanceManager.specialCircumstance = null } just runs
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.LifecycleResumed)
assertEquals(
VaultEvent.NavigateToVaultSearchScreen, awaitItem(),
)
}
}
private fun createViewModel(): VaultViewModel =
VaultViewModel(
authRepository = authRepository,
@ -1854,6 +1896,7 @@ class VaultViewModelTest : BaseViewModelTest() {
firstTimeActionManager = firstTimeActionManager,
snackbarRelayManager = snackbarRelayManager,
reviewPromptManager = reviewPromptManager,
specialCircumstanceManager = specialCircumstanceManager,
)
}

View File

@ -12,6 +12,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
@ -41,6 +42,7 @@ class VerificationCodeScreenTest : BaseComposeTest() {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
private val appResumeStateManager: AppResumeStateManager = mockk(relaxed = true)
@Before
fun setUp() {
@ -50,6 +52,7 @@ class VerificationCodeScreenTest : BaseComposeTest() {
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToVaultItemScreen = { onNavigateToVaultItemId = it },
onNavigateToSearch = { onNavigateToSearchCalled = true },
appResumeStateManager = appResumeStateManager,
)
}
}