diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f2ee6a904d..3ccec0217f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,7 @@ @@ -44,6 +44,15 @@ android:host="captcha-callback" android:scheme="bitwarden" /> + + + + + + + + + ( MainState( @@ -37,6 +40,7 @@ class MainViewModel @Inject constructor( override fun handleAction(action: MainAction) { when (action) { is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action) + is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action) is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action) } } @@ -45,8 +49,22 @@ class MainViewModel @Inject constructor( mutableStateFlow.update { it.copy(theme = action.theme) } } + private fun handleFirstIntentReceived(action: MainAction.ReceiveFirstIntent) { + val shareData = intentManager.getShareDataFromIntent(action.intent) + when { + shareData != null -> { + authRepository.specialCircumstance = + UserState.SpecialCircumstance.ShareNewSend( + data = shareData, + shouldFinishWhenComplete = true, + ) + } + } + } + private fun handleNewIntentReceived(action: MainAction.ReceiveNewIntent) { val captchaCallbackTokenResult = action.intent.getCaptchaCallbackTokenResult() + val shareData = intentManager.getShareDataFromIntent(action.intent) when { captchaCallbackTokenResult != null -> { authRepository.setCaptchaCallbackTokenResult( @@ -54,6 +72,16 @@ class MainViewModel @Inject constructor( ) } + shareData != null -> { + authRepository.specialCircumstance = + UserState.SpecialCircumstance.ShareNewSend( + data = shareData, + // Allow users back into the already-running app when completing the + // Send task. + shouldFinishWhenComplete = false, + ) + } + else -> Unit } } @@ -71,6 +99,11 @@ data class MainState( * Models actions for the [MainActivity]. */ sealed class MainAction { + /** + * Receive first Intent by the application. + */ + data class ReceiveFirstIntent(val intent: Intent) : MainAction() + /** * Receive Intent by the application. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index c075e6a8d6..24ec6d4857 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -48,6 +48,15 @@ interface AuthRepository : AuthenticatorProvider { */ var specialCircumstance: UserState.SpecialCircumstance? + /** + * Tracks whether there is an additional account that is pending login/registration in order to + * have multiple accounts available. + * + * This allows a direct view into and modification of [UserState.hasPendingAccountAddition]. + * Note that this call has no effect when there is no [UserState] information available. + */ + var hasPendingAccountAddition: Boolean + /** * Attempt to delete the current account and logout them out upon success. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 75e2f88d9c..56f1fb9c51 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -73,6 +73,7 @@ class AuthRepositoryImpl( dispatcherManager: DispatcherManager, private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() }, ) : AuthRepository { + private val mutableHasPendingAccountAdditionStateFlow = MutableStateFlow(false) private val mutableSpecialCircumstanceStateFlow = MutableStateFlow(null) @@ -107,12 +108,20 @@ class AuthRepositoryImpl( authDiskSource.userStateFlow, authDiskSource.userOrganizationsListFlow, vaultRepository.vaultStateFlow, + mutableHasPendingAccountAdditionStateFlow, mutableSpecialCircumstanceStateFlow, - ) { userStateJson, userOrganizationsList, vaultState, specialCircumstance -> + ) { + userStateJson, + userOrganizationsList, + vaultState, + hasPendingAccountAddition, + specialCircumstance, + -> userStateJson ?.toUserState( vaultState = vaultState, userOrganizationsList = userOrganizationsList, + hasPendingAccountAddition = hasPendingAccountAddition, specialCircumstance = specialCircumstance, vaultUnlockTypeProvider = ::getVaultUnlockType, ) @@ -125,6 +134,7 @@ class AuthRepositoryImpl( ?.toUserState( vaultState = vaultRepository.vaultStateFlow.value, userOrganizationsList = authDiskSource.userOrganizationsList, + hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value, specialCircumstance = mutableSpecialCircumstanceStateFlow.value, vaultUnlockTypeProvider = ::getVaultUnlockType, ), @@ -140,6 +150,9 @@ class AuthRepositoryImpl( override var specialCircumstance: UserState.SpecialCircumstance? by mutableSpecialCircumstanceStateFlow::value + override var hasPendingAccountAddition: Boolean + by mutableHasPendingAccountAdditionStateFlow::value + override suspend fun deleteAccount(password: String): DeleteAccountResult { val profile = authDiskSource.userState?.activeAccount?.profile ?: return DeleteAccountResult.Error @@ -218,7 +231,7 @@ class AuthRepositoryImpl( userId = userStateJson.activeUserId, ) vaultRepository.sync() - specialCircumstance = null + hasPendingAccountAddition = false LoginResult.Success } @@ -268,8 +281,8 @@ class AuthRepositoryImpl( val previousActiveUserId = currentUserState.activeUserId if (userId == previousActiveUserId) { - // No switching to do but clear any special circumstances - specialCircumstance = null + // No switching to do but clear any pending account additions + hasPendingAccountAddition = false return SwitchAccountResult.NoChange } @@ -284,8 +297,8 @@ class AuthRepositoryImpl( // Clear data for the previous user vaultRepository.clearUnlockedData() - // Clear any special circumstances - specialCircumstance = null + // Clear any pending account additions + hasPendingAccountAddition = false return SwitchAccountResult.AccountSwitched } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt index 906006f65c..9206032d06 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.model import com.x8bit.bitwarden.data.auth.repository.model.UserState.Account import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager /** * Represents the overall "user state" of the current active user as well as any users that may be @@ -10,11 +11,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.Environment * @property activeUserId The ID of the current active user. * @property accounts A mapping between user IDs and the [Account] information associated with * that user. + * @property hasPendingAccountAddition Returns `true` if there is an additional account that is + * pending login/registration in order to have multiple accounts available. * @property specialCircumstance A special circumstance (if any) that may be present. */ data class UserState( val activeUserId: String, val accounts: List, + val hasPendingAccountAddition: Boolean = false, val specialCircumstance: SpecialCircumstance? = null, ) { init { @@ -27,12 +31,6 @@ data class UserState( val activeAccount: Account get() = accounts.first { it.userId == activeUserId } - /** - * Returns `true` if a new user is in the process of being added, `false` otherwise. - */ - val hasPendingAccountAddition: Boolean - get() = specialCircumstance == SpecialCircumstance.PendingAccountAddition - /** * Basic account information about a given user. * @@ -65,11 +63,12 @@ data class UserState( * Represents a special account-related circumstance. */ sealed class SpecialCircumstance { - /** - * There is an additional account that is pending login/registration in order to have - * multiple accounts available. + * The app was launched in order to create/share a new Send using the given [data]. */ - data object PendingAccountAddition : SpecialCircumstance() + data class ShareNewSend( + val data: IntentManager.ShareData, + val shouldFinishWhenComplete: Boolean, + ) : SpecialCircumstance() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt index 7fe49efe3a..4d789aae2a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt @@ -47,6 +47,7 @@ fun UserStateJson.toUpdatedUserStateJson( fun UserStateJson.toUserState( vaultState: VaultState, userOrganizationsList: List, + hasPendingAccountAddition: Boolean, specialCircumstance: UserState.SpecialCircumstance?, vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType, ): UserState = @@ -77,5 +78,6 @@ fun UserStateJson.toUserState( vaultUnlockType = vaultUnlockTypeProvider(userId), ) }, + hasPendingAccountAddition = hasPendingAccountAddition, specialCircumstance = specialCircumstance, ) 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 8a05fe2815..69cf18a391 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 @@ -31,7 +31,7 @@ import java.time.Clock import javax.inject.Singleton /** - * Provides repositories in the auth package. + * Provides managers in the platform package. */ @Module @InstallIn(SingletonComponent::class) 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 cf7cd7ba4d..a54fadcd1c 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 @@ -98,7 +98,7 @@ class VaultUnlockViewModel @Inject constructor( } private fun handleAddAccountClick() { - authRepository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition + authRepository.hasPendingAccountAddition = true } private fun handleDismissDialog() { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 5e8ccea84b..512c901c37 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -20,9 +20,12 @@ import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.splash.navigateToSplash import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestination +import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_GRAPH_ROUTE +import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlockedForNewSendGraph import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlockedGraph import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedGraph +import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedGraphForNewSend import com.x8bit.bitwarden.ui.platform.theme.RootTransitionProviders import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -31,6 +34,7 @@ import java.util.concurrent.atomic.AtomicReference /** * Controls root level [NavHost] for the app. */ +@Suppress("LongMethod") @Composable fun RootNavScreen( viewModel: RootNavViewModel = hiltViewModel(), @@ -66,6 +70,7 @@ fun RootNavScreen( authGraph(navController) vaultUnlockDestination() vaultUnlockedGraph(navController) + vaultUnlockedGraphForNewSend(navController) } val targetRoute = when (state) { @@ -73,6 +78,7 @@ fun RootNavScreen( RootNavState.Splash -> SPLASH_ROUTE RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE is RootNavState.VaultUnlocked -> VAULT_UNLOCKED_GRAPH_ROUTE + RootNavState.VaultUnlockedForNewSend -> VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE } val currentRoute = navController.currentDestination?.rootLevelRoute() @@ -102,6 +108,9 @@ fun RootNavScreen( RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions) is RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(rootNavOptions) + RootNavState.VaultUnlockedForNewSend -> { + navController.navigateToVaultUnlockedForNewSendGraph(rootNavOptions) + } } } 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 12d3412486..79c65a0b9a 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 @@ -51,9 +51,18 @@ class RootNavViewModel @Inject constructor( userState.hasPendingAccountAddition -> RootNavState.Auth userState.activeAccount.isVaultUnlocked -> { - RootNavState.VaultUnlocked( - activeUserId = userState.activeAccount.userId, - ) + when (userState.specialCircumstance) { + is UserState.SpecialCircumstance.ShareNewSend -> { + RootNavState.VaultUnlockedForNewSend + } + + null, + -> { + RootNavState.VaultUnlocked( + activeUserId = userState.activeAccount.userId, + ) + } + } } else -> RootNavState.VaultLocked @@ -91,6 +100,12 @@ sealed class RootNavState : Parcelable { data class VaultUnlocked( val activeUserId: String, ) : RootNavState() + + /** + * App should show the new send screen for an unlocked user. + */ + @Parcelize + data object VaultUnlockedForNewSend : RootNavState() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 1c79f3d469..15157f512e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -14,6 +14,8 @@ import com.x8bit.bitwarden.ui.tools.feature.generator.generatorModalDestination import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGeneratorModal import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.navigateToPasswordHistory import com.x8bit.bitwarden.ui.tools.feature.generator.passwordhistory.passwordHistoryDestination +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.ADD_SEND_AS_ROOT_ROUTE +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.addSendAsRootDestination import com.x8bit.bitwarden.ui.tools.feature.send.addsend.addSendDestination import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType import com.x8bit.bitwarden.ui.tools.feature.send.addsend.navigateToAddSend @@ -30,6 +32,7 @@ import com.x8bit.bitwarden.ui.vault.feature.qrcodescan.vaultQrCodeScanDestinatio import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType const val VAULT_UNLOCKED_GRAPH_ROUTE: String = "vault_unlocked_graph" +const val VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE: String = "vault_unlocked_for_new_send_graph" /** * Navigate to the vault unlocked screen. @@ -38,6 +41,13 @@ fun NavController.navigateToVaultUnlockedGraph(navOptions: NavOptions? = null) { navigate(VAULT_UNLOCKED_GRAPH_ROUTE, navOptions) } +/** + * Navigate to the vault unlocked graph for a new send. + */ +fun NavController.navigateToVaultUnlockedForNewSendGraph(navOptions: NavOptions? = null) { + navigate(VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE, navOptions) +} + /** * Add vault unlocked destinations to the root nav graph. */ @@ -107,3 +117,17 @@ fun NavGraphBuilder.vaultUnlockedGraph( generatorModalDestination(onNavigateBack = { navController.popBackStack() }) } } + +/** + * Add vault unlocked destinations for the new send flow to the root nav graph. + */ +fun NavGraphBuilder.vaultUnlockedGraphForNewSend( + navController: NavController, +) { + navigation( + startDestination = ADD_SEND_AS_ROOT_ROUTE, + route = VAULT_UNLOCKED_FOR_NEW_SEND_GRAPH_ROUTE, + ) { + addSendAsRootDestination(onNavigateBack = { navController.popBackStack() }) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/di/PlatformUiManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/di/PlatformUiManagerModule.kt new file mode 100644 index 0000000000..33dd5dd0ca --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/di/PlatformUiManagerModule.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.ui.platform.manager.di + +import android.content.Context +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManagerImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent + +/** + * Provides UI-based managers in the platform package. + */ +@Module +@InstallIn(SingletonComponent::class) +class PlatformUiManagerModule { + @Provides + fun provideIntentManager( + @ApplicationContext context: Context, + ): IntentManager = + IntentManagerImpl( + context = context, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt index ce4df7f54d..ef0c7b1f6d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable /** * A manager class for simplifying the handling of Android Intents within a given context. */ +@Suppress("TooManyFunctions") interface IntentManager { /** @@ -61,6 +62,11 @@ interface IntentManager { */ fun getFileDataFromIntent(activityResult: ActivityResult): FileData? + /** + * Processes the [intent] and attempts to derive [ShareData] information from it. + */ + fun getShareDataFromIntent(intent: Intent): ShareData? + /** * Creates an intent for choosing a file saved to disk. */ @@ -74,4 +80,24 @@ interface IntentManager { val uri: Uri, val sizeBytes: Long, ) + + /** + * Represents data for a share request coming from outside the app. + */ + sealed class ShareData { + /** + * The data required to create a new Text Send. + */ + data class TextSend( + val subject: String?, + val text: String, + ) : ShareData() + + /** + * The data required to create a new File Send. + */ + data class FileSend( + val fileData: IntentManager.FileData, + ) : ShareData() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index 4dd5979cc4..9919a10bec 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -122,6 +122,31 @@ class IntentManagerImpl( return if (uri != null) getLocalFileData(uri) else getCameraFileData() } + @Suppress("ReturnCount") + override fun getShareDataFromIntent(intent: Intent): IntentManager.ShareData? { + if (intent.action != Intent.ACTION_SEND) return null + return if (intent.type?.contains("text/") == true) { + val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) + val title = intent.getStringExtra(Intent.EXTRA_TEXT) ?: return null + IntentManager.ShareData.TextSend( + subject = subject, + text = title, + ) + } else { + getFileDataFromIntent( + ActivityResult( + Activity.RESULT_OK, + intent, + ), + ) + ?.let { + IntentManager.ShareData.FileSend( + fileData = it, + ) + } + } + } + override fun createFileChooserIntent(withCameraIntents: Boolean): Intent { val chooserIntent = Intent.createChooser( Intent(Intent.ACTION_OPEN_DOCUMENT) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendNavigation.kt index 9bf6ae03bf..89261b9fcf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendNavigation.kt @@ -20,6 +20,8 @@ private const val ADD_SEND_ITEM_TYPE: String = "add_send_item_type" private const val ADD_SEND_ROUTE: String = "$ADD_SEND_ITEM_PREFIX/{$ADD_SEND_ITEM_TYPE}?$EDIT_ITEM_ID={$EDIT_ITEM_ID}" +const val ADD_SEND_AS_ROOT_ROUTE: String = ADD_SEND_ITEM_PREFIX + /** * Class to retrieve send add & edit arguments from the [SavedStateHandle]. */ @@ -28,9 +30,10 @@ data class AddSendArgs( val sendAddType: AddSendType, ) { constructor(savedStateHandle: SavedStateHandle) : this( - sendAddType = when (requireNotNull(savedStateHandle[ADD_SEND_ITEM_TYPE])) { + sendAddType = when (savedStateHandle.get(ADD_SEND_ITEM_TYPE)) { ADD_TYPE -> AddSendType.AddItem EDIT_TYPE -> AddSendType.EditItem(requireNotNull(savedStateHandle[EDIT_ITEM_ID])) + null -> AddSendType.AddItem else -> throw IllegalStateException("Unknown VaultAddEditType.") }, ) @@ -52,6 +55,19 @@ fun NavGraphBuilder.addSendDestination( } } +/** + * Add the new send screen to the nav graph as a root destination for a nested graph. + */ +fun NavGraphBuilder.addSendAsRootDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = ADD_SEND_AS_ROOT_ROUTE, + ) { + AddSendScreen(onNavigateBack = onNavigateBack) + } +} + /** * Navigate to the new send screen. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt index 262540bcda..9f8cc8a90a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt @@ -101,6 +101,9 @@ class AddSendViewModel @Inject constructor( ) { init { + // TODO: Check the special circumstance to place in custom mode when a new send request is + // initiated externally (BIT-1518). + when (val addSendType = state.addSendType) { AddSendType.AddItem -> Unit is AddSendType.EditItem -> { 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 b87eff20ee..619bb4c09f 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 @@ -205,7 +205,7 @@ class VaultViewModel @Inject constructor( } private fun handleAddAccountClick() { - authRepository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition + authRepository.hasPendingAccountAddition = true } private fun handleSyncClick() { diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 2e02117bd8..c048a0b730 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -4,17 +4,23 @@ import android.content.Intent import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult +import com.x8bit.bitwarden.data.auth.repository.util.getCaptchaCallbackTokenResult import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import io.mockk.runs +import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class MainViewModelTest : BaseViewModelTest() { @@ -24,18 +30,27 @@ class MainViewModelTest : BaseViewModelTest() { val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow every { activeUserId } returns USER_ID - every { - setCaptchaCallbackTokenResult( - tokenResult = CaptchaCallbackTokenResult.Success( - token = "mockk_token", - ), - ) - } just runs + every { specialCircumstance } returns null + every { specialCircumstance = any() } just runs + every { setCaptchaCallbackTokenResult(any()) } just runs } private val settingsRepository = mockk { every { appTheme } returns AppTheme.DEFAULT every { appThemeStateFlow } returns mutableAppThemeFlow } + private val intentManager: IntentManager = mockk { + every { getShareDataFromIntent(any()) } returns null + } + + @BeforeEach + fun setUp() { + mockkStatic(CAPTCHA_UTILS_PATH) + } + + @AfterEach + fun tearDown() { + unmockkStatic(CAPTCHA_UTILS_PATH) + } @Test fun `on AppThemeChanged should update state`() { @@ -65,14 +80,37 @@ class MainViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with share data should set the special circumstance to ShareNewSend`() { + val viewModel = createViewModel() + val mockIntent = mockk() + val shareData = mockk() + every { mockIntent.getCaptchaCallbackTokenResult() } returns null + every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + verify { + authRepository.specialCircumstance = UserState.SpecialCircumstance.ShareNewSend( + data = shareData, + shouldFinishWhenComplete = true, + ) + } + } + @Test fun `on ReceiveNewIntent with captcha host should call setCaptchaCallbackToken`() { val viewModel = createViewModel() - val mockIntent = mockk { - every { data?.host } returns "captcha-callback" - every { data?.getQueryParameter("token") } returns "mockk_token" - every { action } returns Intent.ACTION_VIEW - } + val mockIntent = mockk() + every { + mockIntent.getCaptchaCallbackTokenResult() + } returns CaptchaCallbackTokenResult.Success( + token = "mockk_token", + ) viewModel.trySendAction( MainAction.ReceiveNewIntent( intent = mockIntent, @@ -87,12 +125,37 @@ class MainViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `on ReceiveNewIntent with share data should set the special circumstance to ShareNewSend`() { + val viewModel = createViewModel() + val mockIntent = mockk() + val shareData = mockk() + every { mockIntent.getCaptchaCallbackTokenResult() } returns null + every { intentManager.getShareDataFromIntent(mockIntent) } returns shareData + + viewModel.trySendAction( + MainAction.ReceiveNewIntent( + intent = mockIntent, + ), + ) + verify { + authRepository.specialCircumstance = UserState.SpecialCircumstance.ShareNewSend( + data = shareData, + shouldFinishWhenComplete = false, + ) + } + } + private fun createViewModel() = MainViewModel( authRepository = authRepository, settingsRepository = settingsRepository, + intentManager = intentManager, ) companion object { + private const val CAPTCHA_UTILS_PATH = + "com.x8bit.bitwarden.data.auth.repository.util.CaptchaUtilsKt" private const val USER_ID = "userID" private val DEFAULT_USER_STATE = UserState( activeUserId = USER_ID, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 8b477a5ee4..149b969b1d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -68,6 +68,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach @@ -217,6 +218,7 @@ class AuthRepositoryTest { SINGLE_USER_STATE_1.toUserState( vaultState = VAULT_STATE, userOrganizationsList = emptyList(), + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ), @@ -238,6 +240,7 @@ class AuthRepositoryTest { MULTI_USER_STATE.toUserState( vaultState = VAULT_STATE, userOrganizationsList = emptyList(), + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.PIN }, ), @@ -253,6 +256,7 @@ class AuthRepositoryTest { MULTI_USER_STATE.toUserState( vaultState = emptyVaultState, userOrganizationsList = emptyList(), + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.PIN }, ), @@ -277,6 +281,7 @@ class AuthRepositoryTest { MULTI_USER_STATE.toUserState( vaultState = emptyVaultState, userOrganizationsList = USER_ORGANIZATIONS, + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ), @@ -306,6 +311,7 @@ class AuthRepositoryTest { val initialUserState = SINGLE_USER_STATE_1.toUserState( vaultState = VAULT_STATE, userOrganizationsList = emptyList(), + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ) @@ -316,11 +322,12 @@ class AuthRepositoryTest { repository.userStateFlow.value, ) - repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition + val mockSpecialCircumstance: UserState.SpecialCircumstance = mockk() + repository.specialCircumstance = mockSpecialCircumstance assertEquals( initialUserState.copy( - specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition, + specialCircumstance = mockSpecialCircumstance, ), repository.userStateFlow.value, ) @@ -595,7 +602,7 @@ class AuthRepositoryTest { runTest { // Ensure the initial state for User 2 with a account addition fakeAuthDiskSource.userState = SINGLE_USER_STATE_2 - repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition + repository.hasPendingAccountAddition = true // Set up login for User 1 val successResponse = GET_TOKEN_RESPONSE_SUCCESS @@ -665,7 +672,7 @@ class AuthRepositoryTest { MULTI_USER_STATE, fakeAuthDiskSource.userState, ) - assertNull(repository.specialCircumstance) + assertFalse(repository.hasPendingAccountAddition) verify { settingsRepository.setDefaultsIfNecessary(userId = USER_ID_1) } verify { vaultRepository.clearUnlockedData() } } @@ -1076,11 +1083,12 @@ class AuthRepositoryTest { @Suppress("MaxLineLength") @Test - fun `switchAccount when the given userId is the same as the current activeUserId should only clear any special circumstances`() { + fun `switchAccount when the given userId is the same as the current activeUserId should reset any pending account additions`() { val originalUserId = USER_ID_1 val originalUserState = SINGLE_USER_STATE_1.toUserState( vaultState = VAULT_STATE, userOrganizationsList = emptyList(), + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ) @@ -1089,7 +1097,7 @@ class AuthRepositoryTest { originalUserState, repository.userStateFlow.value, ) - repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition + repository.hasPendingAccountAddition = true assertEquals( SwitchAccountResult.NoChange, @@ -1100,7 +1108,7 @@ class AuthRepositoryTest { originalUserState, repository.userStateFlow.value, ) - assertNull(repository.specialCircumstance) + assertFalse(repository.hasPendingAccountAddition) verify(exactly = 0) { vaultRepository.clearUnlockedData() } } @@ -1111,6 +1119,7 @@ class AuthRepositoryTest { val originalUserState = SINGLE_USER_STATE_1.toUserState( vaultState = VAULT_STATE, userOrganizationsList = emptyList(), + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ) @@ -1134,11 +1143,12 @@ class AuthRepositoryTest { @Suppress("MaxLineLength") @Test - fun `switchAccount when the userId is valid should update the current UserState, clear the previously unlocked data, and reset the special circumstance`() { + fun `switchAccount when the userId is valid should update the current UserState, clear the previously unlocked data, and reset any pending account additions`() { val updatedUserId = USER_ID_2 val originalUserState = MULTI_USER_STATE.toUserState( vaultState = VAULT_STATE, userOrganizationsList = emptyList(), + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ) @@ -1147,7 +1157,7 @@ class AuthRepositoryTest { originalUserState, repository.userStateFlow.value, ) - repository.specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition + repository.hasPendingAccountAddition = true assertEquals( SwitchAccountResult.AccountSwitched, @@ -1158,7 +1168,7 @@ class AuthRepositoryTest { originalUserState.copy(activeUserId = updatedUserId), repository.userStateFlow.value, ) - assertNull(repository.specialCircumstance) + assertFalse(repository.hasPendingAccountAddition) verify { vaultRepository.clearUnlockedData() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index 46424c2530..8434eed890 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -154,6 +154,7 @@ class UserStateJsonExtensionsTest { ), ), ), + hasPendingAccountAddition = false, specialCircumstance = null, vaultUnlockTypeProvider = { VaultUnlockType.PIN }, ), @@ -185,7 +186,8 @@ class UserStateJsonExtensionsTest { vaultUnlockType = VaultUnlockType.MASTER_PASSWORD, ), ), - specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition, + hasPendingAccountAddition = true, + specialCircumstance = MOCK_SPECIAL_CIRCUMSTANCE, ), UserStateJson( activeUserId = "activeUserId", @@ -224,9 +226,12 @@ class UserStateJsonExtensionsTest { ), ), ), - specialCircumstance = UserState.SpecialCircumstance.PendingAccountAddition, + hasPendingAccountAddition = true, + specialCircumstance = MOCK_SPECIAL_CIRCUMSTANCE, vaultUnlockTypeProvider = { VaultUnlockType.MASTER_PASSWORD }, ), ) } } + +private val MOCK_SPECIAL_CIRCUMSTANCE: UserState.SpecialCircumstance = mockk() 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 f9e73b060c..159f0c50b8 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 @@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJso import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState -import com.x8bit.bitwarden.data.auth.repository.model.UserState.SpecialCircumstance import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment @@ -38,8 +37,8 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { private val authRepository = mockk() { every { activeUserId } answers { mutableUserStateFlow.value?.activeUserId } every { userStateFlow } returns mutableUserStateFlow - every { specialCircumstance } returns null - every { specialCircumstance = any() } just runs + every { hasPendingAccountAddition } returns false + every { hasPendingAccountAddition = any() } just runs every { logout() } just runs every { logout(any()) } just runs every { switchAccount(any()) } returns SwitchAccountResult.AccountSwitched @@ -174,11 +173,11 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on AddAccountClick should update the SpecialCircumstance of the AuthRepository to PendingAccountAddition`() { + fun `on AddAccountClick should set hasPendingAccountAddition to true on the AuthRepository`() { val viewModel = createViewModel() viewModel.trySendAction(VaultUnlockAction.AddAccountClick) verify { - authRepository.specialCircumstance = SpecialCircumstance.PendingAccountAddition + authRepository.hasPendingAccountAddition = true } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index e3bff6ec1a..567f9ddfc2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -89,5 +89,14 @@ class RootNavScreenTest : BaseComposeTest() { navOptions = expectedNavOptions, ) } + + // Make sure navigating to vault unlocked works as expected: + rootNavStateFlow.value = RootNavState.VaultUnlockedForNewSend + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "vault_unlocked_for_new_send_graph", + navOptions = expectedNavOptions, + ) + } } } 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 17b6e04431..dafc6cfb50 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 @@ -6,7 +6,6 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.Organization import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState -import com.x8bit.bitwarden.data.auth.repository.model.UserState.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 @@ -53,8 +52,8 @@ class VaultViewModelTest : BaseViewModelTest() { private val authRepository: AuthRepository = mockk { every { userStateFlow } returns mutableUserStateFlow - every { specialCircumstance } returns null - every { specialCircumstance = any() } just runs + every { hasPendingAccountAddition } returns false + every { hasPendingAccountAddition = any() } just runs every { logout(any()) } just runs every { switchAccount(any()) } answers { switchAccountResult } } @@ -289,11 +288,11 @@ class VaultViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `on AddAccountClick should update the SpecialCircumstance of the AuthRepository to PendingAccountAddition`() { + fun `on AddAccountClick should set hasPendingAccountAddition to true on the AuthRepository`() { val viewModel = createViewModel() viewModel.trySendAction(VaultAction.AddAccountClick) verify { - authRepository.specialCircumstance = SpecialCircumstance.PendingAccountAddition + authRepository.hasPendingAccountAddition = true } }