diff --git a/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt b/app/src/main/java/com/x8bit/bitwarden/MainActivity.kt index 6341a2bfa6a..5130f9f73e0 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 bb9658870bb..54ad572c6ae 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 3f49b5135df..6f47f54a9f6 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 22805eb9843..11d5d1a4e26 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 976ed2fee6d..7cd600bf2a5 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 0a15d2864e4..a1ea2144a6e 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 00000000000..25e33ad8192 --- /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 00000000000..f56797efb8a --- /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 311458e6e17..b7869e69e52 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 @@ -64,6 +66,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 @@ -329,4 +332,22 @@ object PlatformManagerModule { autofillEnabledManager = autofillEnabledManager, accessibilityEnabledManager = accessibilityEnabledManager, ) + + @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 00000000000..d5cb94f8c36 --- /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 d9612ff9152..885d75c1bdb 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 00000000000..d91643c2820 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/AppResumeStateManager.kt @@ -0,0 +1,78 @@ +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(appResumeStateProvider: () -> AppResumeScreenData) { + val appResumeStateManager = LocalAppResumeStateManager.current + 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 4d0fdbd53e2..e74bab2de0e 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 f32eeecb52f..8e0adbbac19 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 fa1687ea36b..c2ef5fff9d3 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 0c7edbf0e8d..335b41f17a9 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 b9a4e62cd6a..e5acfd5d984 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 f3465beeeb5..fa5f90efb8e 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,8 @@ 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.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 @@ -52,6 +54,13 @@ fun SearchScreen( val state by viewModel.stateFlow.collectAsStateWithLifecycle() val searchHandlers = remember(viewModel) { SearchHandlers.create(viewModel) } val context = LocalContext.current + + RegisterScreenDataOnLifecycleEffect { + 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 6997b9e41e3..f5acd71e49b 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 @@ -87,9 +88,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 d40df2294c2..a0d2f7ec996 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 @@ -90,7 +90,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 691e9ba2610..4d1b7c19c16 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 9dc0de437df..eb9b50eb77d 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 @@ -33,6 +33,8 @@ 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.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 @@ -115,6 +117,9 @@ fun GeneratorScreen( else -> Unit } } + RegisterScreenDataOnLifecycleEffect { + AppResumeScreenData.GeneratorScreen + } EventsEffect(viewModel = viewModel) { event -> when (event) { 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 89527952587..2978f0adbe5 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,8 @@ 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.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 @@ -64,6 +66,9 @@ fun SendScreen( { viewModel.trySendAction(SendAction.RefreshPull) } }, ) + RegisterScreenDataOnLifecycleEffect { + AppResumeScreenData.SendScreen + } EventsEffect(viewModel = viewModel) { event -> when (event) { 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 5ce3902792b..5c09b9a451a 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 @@ -78,6 +80,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) @@ -208,6 +211,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 59869db549d..0dfaa7cccec 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,8 @@ 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.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 @@ -65,6 +67,10 @@ fun VerificationCodeScreen( }, ) + RegisterScreenDataOnLifecycleEffect { + 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 ef333dacd11..e496a0e6bed 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 e85e7cec0a9..04e0677b43e 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 3115bf96a43..37150880f9a 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 assertNotNullLastLockTimestamp(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 ae924034371..65c8373cb06 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 16d011aeccf..8f273463abd 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 00000000000..824b2c1c4f9 --- /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 00000000000..a473b7009a6 --- /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 e83827cb1d5..850ebd1a15e 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.assertNotNullLastLockTimestamp( + 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 39942495c31..415b6b00692 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 b64611114a5..056d768dadb 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 @@ -763,34 +763,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( @@ -801,6 +774,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`() { @@ -1387,3 +1399,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 bd4ba8a25fa..e90fe62a146 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.platform.feature.search +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed @@ -18,9 +19,11 @@ 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 +import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForCipher import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForSend @@ -55,6 +58,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 @@ -63,14 +68,16 @@ class SearchScreenTest : BaseComposeTest() { @Before fun setup() { composeTestRule.setContent { - SearchScreen( - viewModel = viewModel, - intentManager = intentManager, - onNavigateBack = { onNavigateBackCalled = true }, - onNavigateToEditSend = { onNavigateToEditSendId = it }, - onNavigateToEditCipher = { onNavigateToEditCipherId = it }, - onNavigateToViewCipher = { onNavigateToViewCipherId = it }, - ) + CompositionLocalProvider(LocalAppResumeStateManager provides appResumeStateManager) { + SearchScreen( + viewModel = viewModel, + intentManager = intentManager, + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToEditSend = { onNavigateToEditSendId = it }, + onNavigateToEditCipher = { onNavigateToEditCipherId = it }, + onNavigateToViewCipher = { onNavigateToViewCipherId = it }, + ) + } } } 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 70eb8d1fa4d..60c547fa1e3 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 99c8c69b0b2..cc03d0ea40c 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.tools.feature.generator +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher.Companion.expectValue @@ -29,9 +30,11 @@ 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 +import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import io.mockk.every @@ -58,16 +61,21 @@ 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 }, - onNavigateBack = {}, - intentManager = intentManager, - ) + CompositionLocalProvider(LocalAppResumeStateManager provides appResumeStateManager) { + GeneratorScreen( + viewModel = viewModel, + onNavigateToPasswordHistory = { + onNavigateToPasswordHistoryScreenCalled = true + }, + onNavigateBack = {}, + intentManager = intentManager, + ) + } } } 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 59edd2f7742..20e27a94f84 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.tools.feature.send +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed @@ -22,9 +23,11 @@ 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 +import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.util.assertNoDialogExists import com.x8bit.bitwarden.ui.util.assertNoPopupExists @@ -60,19 +63,22 @@ class SendScreenTest : BaseComposeTest() { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } + private val appResumeStateManager: AppResumeStateManager = mockk(relaxed = true) @Before fun setUp() { composeTestRule.setContent { - SendScreen( - viewModel = viewModel, - onNavigateToAddSend = { onNavigateToNewSendCalled = true }, - onNavigateToEditSend = { onNavigateToEditSendId = it }, - onNavigateToSendFilesList = { onNavigateToSendFilesListCalled = true }, - onNavigateToSendTextList = { onNavigateToSendTextListCalled = true }, - onNavigateToSearchSend = { onNavigateToSendSearchCalled = true }, - intentManager = intentManager, - ) + CompositionLocalProvider(LocalAppResumeStateManager provides appResumeStateManager) { + SendScreen( + viewModel = viewModel, + onNavigateToAddSend = { onNavigateToNewSendCalled = true }, + onNavigateToEditSend = { onNavigateToEditSendId = it }, + onNavigateToSendFilesList = { onNavigateToSendFilesListCalled = true }, + onNavigateToSendTextList = { onNavigateToSendTextListCalled = true }, + onNavigateToSearchSend = { onNavigateToSendSearchCalled = true }, + intentManager = intentManager, + ) + } } } 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 14081b857cf..e3d526095d1 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`() { @@ -1853,6 +1860,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, @@ -1866,6 +1908,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 4a54ecef6f0..76342363489 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.verificationcode +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed @@ -12,11 +13,13 @@ 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 import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager import com.x8bit.bitwarden.ui.util.assertNoPopupExists import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import io.mockk.every @@ -41,16 +44,19 @@ class VerificationCodeScreenTest : BaseComposeTest() { every { eventFlow } returns mutableEventFlow every { stateFlow } returns mutableStateFlow } + private val appResumeStateManager: AppResumeStateManager = mockk(relaxed = true) @Before fun setUp() { composeTestRule.setContent { - VerificationCodeScreen( - viewModel = viewModel, - onNavigateBack = { onNavigateBackCalled = true }, - onNavigateToVaultItemScreen = { onNavigateToVaultItemId = it }, - onNavigateToSearch = { onNavigateToSearchCalled = true }, - ) + CompositionLocalProvider(LocalAppResumeStateManager provides appResumeStateManager) { + VerificationCodeScreen( + viewModel = viewModel, + onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToVaultItemScreen = { onNavigateToVaultItemId = it }, + onNavigateToSearch = { onNavigateToSearchCalled = true }, + ) + } } }