From fe06bf48e743f4a53e18ede53af6ce815efe36bc Mon Sep 17 00:00:00 2001 From: aj-rosado <109146700+aj-rosado@users.noreply.github.com> Date: Tue, 28 Jan 2025 19:41:11 +0000 Subject: [PATCH] [PM-13626] Remember last opened view for 5 minutes (#4574) Signed-off-by: Andre Rosado Co-authored-by: Dave Severns --- .../java/com/x8bit/bitwarden/MainActivity.kt | 12 ++ .../java/com/x8bit/bitwarden/MainViewModel.kt | 16 ++ .../auth/datasource/disk/AuthDiskSource.kt | 11 ++ .../datasource/disk/AuthDiskSourceImpl.kt | 16 ++ .../datasource/disk/SettingsDiskSource.kt | 11 ++ .../datasource/disk/SettingsDiskSourceImpl.kt | 13 ++ .../data/platform/manager/AppResumeManager.kt | 37 ++++ .../platform/manager/AppResumeManagerImpl.kt | 74 ++++++++ .../manager/di/PlatformManagerModule.kt | 21 +++ .../manager/model/AppResumeScreenData.kt | 34 ++++ .../manager/model/SpecialCircumstance.kt | 18 ++ .../manager/util/AppResumeStateManager.kt | 80 ++++++++ .../data/vault/manager/VaultLockManager.kt | 1 - .../vault/manager/VaultLockManagerImpl.kt | 4 + .../vaultunlock/VaultUnlockViewModel.kt | 9 + .../composition/LocalManagerProvider.kt | 7 + .../feature/rootnav/RootNavViewModel.kt | 7 +- .../platform/feature/search/SearchScreen.kt | 14 ++ .../feature/search/SearchViewModel.kt | 9 +- .../VaultUnlockedNavBarScreen.kt | 4 +- .../VaultUnlockedNavBarViewModel.kt | 30 +++ .../feature/generator/GeneratorScreen.kt | 10 + .../ui/tools/feature/send/SendScreen.kt | 11 ++ .../ui/vault/feature/vault/VaultViewModel.kt | 19 ++ .../VerificationCodeScreen.kt | 11 ++ .../com/x8bit/bitwarden/MainViewModelTest.kt | 32 +++- .../datasource/disk/AuthDiskSourceTest.kt | 58 +++++- .../disk/util/FakeAuthDiskSource.kt | 19 +- .../datasource/disk/SettingsDiskSourceTest.kt | 51 ++++++ .../disk/util/FakeSettingsDiskSource.kt | 12 ++ .../platform/manager/AppResumeManagerTest.kt | 171 ++++++++++++++++++ .../manager/util/AppResumeStateManagerTest.kt | 22 +++ .../vault/manager/VaultLockManagerTest.kt | 4 + .../vaultunlock/VaultUnlockViewModelTest.kt | 8 + .../feature/rootnav/RootNavViewModelTest.kt | 93 +++++++--- .../feature/search/SearchScreenTest.kt | 4 + .../VaultUnlockedNavBarViewModelTest.kt | 69 +++++++ .../feature/generator/GeneratorScreenTest.kt | 7 +- .../ui/tools/feature/send/SendScreenTest.kt | 3 + .../vault/feature/vault/VaultViewModelTest.kt | 43 +++++ .../VerificationCodeScreenTest.kt | 3 + 41 files changed, 1042 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/AppResumeScreenData.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManager.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManagerTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManagerTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 6341a2bfa6..5130f9f73e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt @@ -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 }, diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index bb9658870b..54ad572c6a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -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( 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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt index 3f49b5135d..6f47f54a9f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSource.kt @@ -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?) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt index 22805eb984..11d5d1a4e2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceImpl.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index 976ed2fee6..7cd600bf2a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -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 + + /** + * 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? } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 0a15d2864e..a1ea2144a6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -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 = diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManager.kt new file mode 100644 index 0000000000..25e33ad819 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManager.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManagerImpl.kt new file mode 100644 index 0000000000..f56797efb8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManagerImpl.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index f7832343bf..da3f2f9a64 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -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, + ) + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/AppResumeScreenData.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/AppResumeScreenData.kt new file mode 100644 index 0000000000..d5cb94f8c3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/AppResumeScreenData.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt index d9612ff915..885d75c1bd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManager.kt new file mode 100644 index 0000000000..07886b936c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManager.kt @@ -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 + + /** + * 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(null) + override val appResumeState: State = 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 + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt index 4d0fdbd53e..e74bab2de0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManager.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt index f32eeecb52..8e0adbbac1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerImpl.kt @@ -281,6 +281,10 @@ class VaultLockManagerImpl( ) if (!wasVaultLocked) { mutableVaultStateEventSharedFlow.tryEmit(VaultStateEvent.Locked(userId = userId)) + authDiskSource.storeLastLockTimestamp( + userId = userId, + lastLockTimestamp = clock.instant(), + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index fa1687ea36..c2ef5fff9d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -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( @@ -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. } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index 0c7edbf0e8..335b41f17a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -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 = compositionLocalOf { error("CompositionLocal AppReviewManager not present") } + +val LocalAppResumeStateManager = compositionLocalOf { + error("CompositionLocal AppResumeStateManager not present") +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index b9a4e62cd6..e5acfd5d98 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt index f3465beeeb..3051ba5a83 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt @@ -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() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index f498b83721..1805c8fb91 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 07ec1754c5..19bfc29d05 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -91,7 +91,9 @@ fun VaultUnlockedNavBarScreen( navigateToVaultGraph(navOptions) } - VaultUnlockedNavBarEvent.NavigateToSendScreen -> { + VaultUnlockedNavBarEvent.Shortcut.NavigateToSendScreen, + VaultUnlockedNavBarEvent.NavigateToSendScreen, + -> { navigateToSendGraph(navOptions) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt index 691e9ba261..4d1b7c19c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModel.kt @@ -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 + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt index 59c98ee34b..d95bcca8c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreen.kt @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt index 8952795258..fa763dff0a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index c0a0e679e7..4e224895ea 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -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( 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) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt index 59869db549..c53a96c452 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt @@ -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() diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index ef333dacd1..e496a0e6be 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -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, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt index e85e7cec0a..04e0677b43 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/AuthDiskSourceTest.kt @@ -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 = """ diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt index 3115bf96a4..605f54287d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/datasource/disk/util/FakeAuthDiskSource.kt @@ -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() private val storedShowImportLogins = mutableMapOf() private val storedNewDeviceNoticeState = mutableMapOf() + private val storedLastLockTimestampState = mutableMapOf() 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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index ae92403437..65c8373cb0 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -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(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(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(it) + }, + ) + settingsDiskSource.storeAppResumeScreen(mockUserId, null) + assertNull( + fakeSharedPreferences.getString(resumeScreenKey, "")?.let { + Json.decodeFromStringOrNull(it) + }, + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index 16d011aecc..8f273463ab 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -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() private var storedSystemBiometricIntegritySource: String? = null private val storedAccountBiometricIntegrityValidity = mutableMapOf() + private val storedAppResumeScreenData = mutableMapOf() private val userSignIns = mutableMapOf() private val userShowAutoFillBadge = mutableMapOf() private val userShowUnlockBadge = mutableMapOf() @@ -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 { return mutableScreenCaptureAllowedFlowMap.getOrPut(userId) { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManagerTest.kt new file mode 100644 index 0000000000..824b2c1c4f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/AppResumeManagerTest.kt @@ -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 { + 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" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManagerTest.kt new file mode 100644 index 0000000000..a473b7009a --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManagerTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt index e83827cb1d..486236308a 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/VaultLockManagerTest.kt @@ -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(), + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index 39942495c3..415b6b0069 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -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, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index d2d2f848c8..6bca299bc4 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -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, + ), + ), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index c1ff41e78b..0794924b28 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -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, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt index 70eb8d1fa4..60c547fa1e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarViewModelTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt index 7bed57aaf5..37a1f01ce5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/generator/GeneratorScreenTest.kt @@ -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, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index 59edd2f774..e0e6c056e7 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -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, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 3441fa5c4e..879a3f6abe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -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(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, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt index 4a54ecef6f..c054e4cb96 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt @@ -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, ) } }