From c9d7a485985eec9782e5c72aacc1ff8a66252a58 Mon Sep 17 00:00:00 2001 From: David Perez Date: Sun, 21 Jan 2024 16:24:53 -0600 Subject: [PATCH] BIT-1518: Process shared sends (#698) --- .../ui/platform/manager/exit/ExitManager.kt | 14 ++ .../platform/manager/exit/ExitManagerImpl.kt | 16 +++ .../platform/manager/intent/IntentManager.kt | 15 +- .../manager/intent/IntentManagerImpl.kt | 24 +--- .../ui/platform/theme/BitwardenTheme.kt | 13 +- .../feature/send/addsend/AddSendContent.kt | 23 ++- .../feature/send/addsend/AddSendScreen.kt | 28 +++- .../feature/send/addsend/AddSendViewModel.kt | 61 ++++++-- .../util/SpecialCircumstanceExtensions.kt | 55 +++++++ .../ui/vault/feature/vault/VaultScreen.kt | 8 +- .../feature/send/addsend/AddSendScreenTest.kt | 43 +++++- .../send/addsend/AddSendViewModelTest.kt | 77 +++++++++- .../util/SpecialCircumstanceExtensionsTest.kt | 135 ++++++++++++++++++ .../ui/vault/feature/vault/VaultScreenTest.kt | 10 +- 14 files changed, 462 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/exit/ExitManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/exit/ExitManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SpecialCircumstanceExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SpecialCircumstanceExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/exit/ExitManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/exit/ExitManager.kt new file mode 100644 index 0000000000..8a258ccf40 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/exit/ExitManager.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.ui.platform.manager.exit + +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage + +/** + * A manager class for handling the various ways to exit the app. + */ +@OmitFromCoverage +interface ExitManager { + /** + * Finishes the activity. + */ + fun exitApplication() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/exit/ExitManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/exit/ExitManagerImpl.kt new file mode 100644 index 0000000000..25e3afeab1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/exit/ExitManagerImpl.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.ui.platform.manager.exit + +import android.app.Activity +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage + +/** + * The default implementation of the [ExitManager] for managing the various ways to exit the app. + */ +@OmitFromCoverage +class ExitManagerImpl( + val activity: Activity, +) : ExitManager { + override fun exitApplication() { + activity.finish() + } +} 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 ef0c7b1f6d..70d18dee7c 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 @@ -11,12 +11,6 @@ import androidx.compose.runtime.Composable */ @Suppress("TooManyFunctions") interface IntentManager { - - /** - * Starts an intent to exit the application. - */ - fun exitApplication() - /** * Start an activity using the provided [Intent]. */ @@ -60,7 +54,12 @@ interface IntentManager { /** * Processes the [activityResult] and attempts to get the relevant file data from it. */ - fun getFileDataFromIntent(activityResult: ActivityResult): FileData? + fun getFileDataFromActivityResult(activityResult: ActivityResult): FileData? + + /** + * Processes the [intent] and attempts to get the relevant file data from it. + */ + fun getFileDataFromIntent(intent: Intent): FileData? /** * Processes the [intent] and attempts to derive [ShareData] information from it. @@ -97,7 +96,7 @@ interface IntentManager { * The data required to create a new File Send. */ data class FileSend( - val fileData: IntentManager.FileData, + val fileData: 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 9919a10bec..91eb7d7dc1 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 @@ -51,16 +51,6 @@ class IntentManagerImpl( private val context: Context, private val clock: Clock = Clock.systemDefaultZone(), ) : IntentManager { - - override fun exitApplication() { - // Note that we fire an explicit Intent rather than try to cast to an Activity and call - // finish to avoid assumptions about what kind of context we have. - val intent = Intent(Intent.ACTION_MAIN).apply { - addCategory(Intent.CATEGORY_HOME) - } - startActivity(intent) - } - override fun startActivity(intent: Intent) { context.startActivity(intent) } @@ -116,12 +106,17 @@ class IntentManagerImpl( startActivity(Intent.createChooser(sendIntent, null)) } - override fun getFileDataFromIntent(activityResult: ActivityResult): IntentManager.FileData? { + override fun getFileDataFromActivityResult( + activityResult: ActivityResult, + ): IntentManager.FileData? { if (activityResult.resultCode != Activity.RESULT_OK) return null val uri = activityResult.data?.data return if (uri != null) getLocalFileData(uri) else getCameraFileData() } + override fun getFileDataFromIntent(intent: Intent): IntentManager.FileData? = + intent.clipData?.getItemAt(0)?.uri?.let { getLocalFileData(it) } + @Suppress("ReturnCount") override fun getShareDataFromIntent(intent: Intent): IntentManager.ShareData? { if (intent.action != Intent.ACTION_SEND) return null @@ -133,12 +128,7 @@ class IntentManagerImpl( text = title, ) } else { - getFileDataFromIntent( - ActivityResult( - Activity.RESULT_OK, - intent, - ), - ) + getFileDataFromIntent(intent = intent) ?.let { IntentManager.ShareData.FileSend( fileData = it, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt index 137c9d4815..42062df0b0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/theme/BitwardenTheme.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme +import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager +import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManagerImpl import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManagerImpl import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager @@ -45,6 +47,7 @@ fun BitwardenTheme( // Get the current scheme val context = LocalContext.current + val activity = context as Activity val colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) @@ -76,8 +79,9 @@ fun BitwardenTheme( CompositionLocalProvider( LocalNonMaterialColors provides nonMaterialColors, LocalNonMaterialTypography provides nonMaterialTypography, - LocalPermissionsManager provides PermissionsManagerImpl(context as Activity), + LocalPermissionsManager provides PermissionsManagerImpl(activity), LocalIntentManager provides IntentManagerImpl(context), + LocalExitManager provides ExitManagerImpl(activity), ) { // Set overall theme based on color scheme and typography settings MaterialTheme( @@ -166,6 +170,13 @@ private fun lightColorScheme(context: Context): ColorScheme = private fun Int.toColor(context: Context): Color = Color(context.getColor(this)) +/** + * Provides access to the exit manager throughout the app. + */ +val LocalExitManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal ExitManager not present") +} + /** * Provides access to the intent manager throughout the app. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt index ce33ae74e7..f5c391e510 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendContent.kt @@ -55,6 +55,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandler fun AddSendContent( state: AddSendState.ViewState.Content, isAddMode: Boolean, + isShared: Boolean, addSendHandlers: AddSendHandlers, permissionsManager: PermissionsManager, modifier: Modifier = Modifier, @@ -77,7 +78,7 @@ fun AddSendContent( onValueChange = addSendHandlers.onNamChange, ) - if (isAddMode) { + if (isAddMode && !isShared) { Spacer(modifier = Modifier.height(16.dp)) BitwardenListHeaderText( label = stringResource(id = R.string.type), @@ -116,7 +117,25 @@ fun AddSendContent( .padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(16.dp)) - if (isAddMode) { + if (isShared) { + Text( + text = type.name.orEmpty(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(id = R.string.max_file_size), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } else if (isAddMode) { Text( modifier = Modifier.align(Alignment.CenterHorizontally), text = type.name ?: stringResource(id = R.string.no_file_chosen), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt index 945b9b70c8..90114962c1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend import android.widget.Toast +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -33,9 +34,12 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState +import com.x8bit.bitwarden.ui.platform.components.NavigationIcon import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData +import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager +import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager import com.x8bit.bitwarden.ui.platform.theme.LocalPermissionsManager import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull @@ -49,6 +53,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandler @Composable fun AddSendScreen( viewModel: AddSendViewModel = hiltViewModel(), + exitManager: ExitManager = LocalExitManager.current, intentManager: IntentManager = LocalIntentManager.current, permissionsManager: PermissionsManager = LocalPermissionsManager.current, onNavigateBack: () -> Unit, @@ -59,13 +64,20 @@ fun AddSendScreen( val resources = context.resources val fileChooserLauncher = intentManager.launchActivityForResult { activityResult -> - intentManager.getFileDataFromIntent(activityResult)?.let { + intentManager.getFileDataFromActivityResult(activityResult)?.let { viewModel.trySendAction(AddSendAction.FileChoose(it)) } } + BackHandler( + onBack = remember(viewModel) { + { viewModel.trySendAction(AddSendAction.CloseClick) } + }, + ) EventsEffect(viewModel = viewModel) { event -> when (event) { + AddSendEvent.ExitApp -> exitManager.exitApplication() + is AddSendEvent.NavigateBack -> onNavigateBack() is AddSendEvent.ShowChooserSheet -> { @@ -115,11 +127,14 @@ fun AddSendScreen( topBar = { BitwardenTopAppBar( title = state.screenDisplayName(), - navigationIcon = painterResource(id = R.drawable.ic_close), - navigationIconContentDescription = stringResource(id = R.string.close), - onNavigationIconClick = remember(viewModel) { - { viewModel.trySendAction(AddSendAction.CloseClick) } - }, + navigationIcon = NavigationIcon( + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(AddSendAction.CloseClick) } + }, + ) + .takeUnless { state.isShared }, scrollBehavior = scrollBehavior, actions = { BitwardenTextButton( @@ -174,6 +189,7 @@ fun AddSendScreen( is AddSendState.ViewState.Content -> AddSendContent( state = viewState, isAddMode = state.isAddMode, + isShared = state.isShared, addSendHandlers = remember(viewModel) { AddSendHandlers.create(viewModel) }, permissionsManager = permissionsManager, modifier = modifier, 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 9f8cc8a90a..763d78d16d 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 @@ -24,6 +24,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.shouldFinishOnComplete +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendName +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendType import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toViewState import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl @@ -54,7 +57,7 @@ private const val MAX_FILE_SIZE_BYTES: Long = 100 * 1024 * 1024 @HiltViewModel class AddSendViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - authRepo: AuthRepository, + private val authRepo: AuthRepository, private val clock: Clock, private val clipboardManager: BitwardenClipboardManager, private val environmentRepo: EnvironmentRepository, @@ -62,13 +65,18 @@ class AddSendViewModel @Inject constructor( ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] ?: run { - val addSendType = AddSendArgs(savedStateHandle).sendAddType + // Check to see if we are navigating here from an external source + val specialCircumstance = authRepo.specialCircumstance + val shareSendType = specialCircumstance.toSendType() + val sendAddType = AddSendArgs(savedStateHandle).sendAddType AddSendState( - addSendType = addSendType, - viewState = when (addSendType) { + shouldFinishOnComplete = specialCircumstance.shouldFinishOnComplete(), + isShared = shareSendType != null, + addSendType = sendAddType, + viewState = when (sendAddType) { AddSendType.AddItem -> AddSendState.ViewState.Content( common = AddSendState.ViewState.Content.Common( - name = "", + name = specialCircumstance.toSendName().orEmpty(), currentAccessCount = null, maxAccessCount = null, passwordInput = "", @@ -85,7 +93,7 @@ class AddSendViewModel @Inject constructor( sendUrl = null, hasPassword = false, ), - selectedType = AddSendState.ViewState.Content.SendType.Text( + selectedType = shareSendType ?: AddSendState.ViewState.Content.SendType.Text( input = "", isHideByDefaultChecked = false, ), @@ -179,12 +187,17 @@ class AddSendViewModel @Inject constructor( is CreateSendResult.Success -> { mutableStateFlow.update { it.copy(dialogState = null) } - sendEvent(AddSendEvent.NavigateBack) - sendEvent( - AddSendEvent.ShowShareSheet( - message = result.sendView.toSendUrl(state.baseWebSendUrl), - ), - ) + if (state.isShared) { + navigateBack() + clipboardManager.setText(result.sendView.toSendUrl(state.baseWebSendUrl)) + } else { + navigateBack() + sendEvent( + AddSendEvent.ShowShareSheet( + message = result.sendView.toSendUrl(state.baseWebSendUrl), + ), + ) + } } } } @@ -209,7 +222,7 @@ class AddSendViewModel @Inject constructor( is UpdateSendResult.Success -> { mutableStateFlow.update { it.copy(dialogState = null) } - sendEvent(AddSendEvent.NavigateBack) + navigateBack() sendEvent( AddSendEvent.ShowShareSheet( message = result.sendView.toSendUrl(state.baseWebSendUrl), @@ -236,7 +249,7 @@ class AddSendViewModel @Inject constructor( is DeleteSendResult.Success -> { mutableStateFlow.update { it.copy(dialogState = null) } - sendEvent(AddSendEvent.NavigateBack) + navigateBack() sendEvent(AddSendEvent.ShowToast(message = R.string.send_deleted.asText())) } } @@ -421,7 +434,7 @@ class AddSendViewModel @Inject constructor( } } - private fun handleCloseClick() = sendEvent(AddSendEvent.NavigateBack) + private fun handleCloseClick() = navigateBack() private fun handleDeletionDateChange(action: AddSendAction.DeletionDateChange) { updateCommonContent { @@ -582,6 +595,17 @@ class AddSendViewModel @Inject constructor( } } + private fun navigateBack() { + authRepo.specialCircumstance = null + sendEvent( + event = if (state.shouldFinishOnComplete) { + AddSendEvent.ExitApp + } else { + AddSendEvent.NavigateBack + }, + ) + } + private inline fun onContent( crossinline block: (AddSendState.ViewState.Content) -> Unit, ) { @@ -645,7 +669,9 @@ data class AddSendState( val addSendType: AddSendType, val dialogState: DialogState?, val viewState: ViewState, + val shouldFinishOnComplete: Boolean, val isPremiumUser: Boolean, + val isShared: Boolean, val baseWebSendUrl: String, ) : Parcelable { @@ -783,6 +809,11 @@ data class AddSendState( * Models events for the new send screen. */ sealed class AddSendEvent { + /** + * Closes the app. + */ + data object ExitApp : AddSendEvent() + /** * Navigate back. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SpecialCircumstanceExtensions.kt new file mode 100644 index 0000000000..57657665c9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SpecialCircumstanceExtensions.kt @@ -0,0 +1,55 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util + +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState + +/** + * Determines the initial [AddSendState.ViewState.Content.SendType] based on the data in the + * [UserState.SpecialCircumstance]. + */ +fun UserState.SpecialCircumstance?.toSendType(): AddSendState.ViewState.Content.SendType? = + when (this) { + is UserState.SpecialCircumstance.ShareNewSend -> { + when (data) { + is IntentManager.ShareData.FileSend -> AddSendState.ViewState.Content.SendType.File( + uri = data.fileData.uri, + name = data.fileData.fileName, + sizeBytes = data.fileData.sizeBytes, + displaySize = null, + ) + + is IntentManager.ShareData.TextSend -> AddSendState.ViewState.Content.SendType.Text( + input = data.text, + isHideByDefaultChecked = false, + ) + } + } + + else -> null + } + +/** + * Determines the initial send name based on the data in the [UserState.SpecialCircumstance]. + */ +fun UserState.SpecialCircumstance?.toSendName(): String? = + when (this) { + is UserState.SpecialCircumstance.ShareNewSend -> { + when (data) { + is IntentManager.ShareData.FileSend -> data.fileData.fileName + is IntentManager.ShareData.TextSend -> data.subject + } + } + + else -> null + } + +/** + * Determines if the [UserState.SpecialCircumstance] requires the app to be closed after completing + * the send. + */ +fun UserState.SpecialCircumstance?.shouldFinishOnComplete(): Boolean = + when (this) { + is UserState.SpecialCircumstance.ShareNewSend -> shouldFinishWhenComplete + else -> false + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt index 45469ac93b..981afd5787 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreen.kt @@ -51,8 +51,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary -import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager -import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager +import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager +import com.x8bit.bitwarden.ui.platform.theme.LocalExitManager import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import kotlinx.collections.immutable.persistentListOf @@ -72,7 +72,7 @@ fun VaultScreen( onNavigateToVerificationCodeScreen: () -> Unit, onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit, onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit, - intentManager: IntentManager = LocalIntentManager.current, + exitManager: ExitManager = LocalExitManager.current, ) { val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current @@ -107,7 +107,7 @@ fun VaultScreen( onNavigateToVaultItemListingScreen(event.itemListingType) } - VaultEvent.NavigateOutOfApp -> intentManager.exitApplication() + VaultEvent.NavigateOutOfApp -> exitManager.exitApplication() is VaultEvent.ShowToast -> { Toast .makeText(context, event.message(context.resources), Toast.LENGTH_SHORT) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt index 57d3f9ad46..7144bb7527 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.test.performTextInput 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.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType @@ -45,6 +46,9 @@ class AddSendScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false + private val exitManager: ExitManager = mockk(relaxed = true) { + every { exitApplication() } just runs + } private val permissionsManager = FakePermissionManager() private val intentManager: IntentManager = mockk(relaxed = true) { every { shareText(any()) } just runs @@ -61,6 +65,7 @@ class AddSendScreenTest : BaseComposeTest() { composeTestRule.setContent { AddSendScreen( viewModel = viewModel, + exitManager = exitManager, intentManager = intentManager, permissionsManager = permissionsManager, onNavigateBack = { onNavigateBackCalled = true }, @@ -74,6 +79,14 @@ class AddSendScreenTest : BaseComposeTest() { assert(onNavigateBackCalled) } + @Test + fun `ExitApp should call exitApplication on ExitManager`() { + mutableEventFlow.tryEmit(AddSendEvent.ExitApp) + verify { + exitManager.exitApplication() + } + } + @Test fun `on ShowShareSheet should call shareText on IntentManager`() { val text = "sharable stuff" @@ -91,6 +104,14 @@ class AddSendScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(AddSendAction.CloseClick) } } + @Test + fun `display navigation icon according to state`() { + mutableStateFlow.update { it.copy(isShared = false) } + composeTestRule.onNodeWithContentDescription("Close").assertIsDisplayed() + mutableStateFlow.update { it.copy(isShared = true) } + composeTestRule.onNodeWithContentDescription("Close").assertDoesNotExist() + } + @Test fun `on save click should send SaveClick`() { composeTestRule @@ -282,7 +303,25 @@ class AddSendScreenTest : BaseComposeTest() { @Test fun `segmented buttons should appear based on state`() { - mutableStateFlow.update { it.copy(addSendType = AddSendType.AddItem) } + mutableStateFlow.update { it.copy(isShared = true) } + composeTestRule + .onNodeWithText("Type") + .assertDoesNotExist() + composeTestRule + .onAllNodesWithText("File") + .filterToOne(!isEditableText) + .assertDoesNotExist() + composeTestRule + .onAllNodesWithText("Text") + .filterToOne(!isEditableText) + .assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + isShared = false, + addSendType = AddSendType.AddItem, + ) + } composeTestRule .onNodeWithText("Type") .performScrollTo() @@ -889,6 +928,8 @@ class AddSendScreenTest : BaseComposeTest() { addSendType = AddSendType.AddItem, viewState = DEFAULT_VIEW_STATE, dialogState = null, + shouldFinishOnComplete = false, + isShared = false, isPremiumUser = false, baseWebSendUrl = "https://vault.bitwarden.com/#/send/", ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt index bf6bfc9e2e..3f4e8242e2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt @@ -56,6 +56,8 @@ class AddSendViewModelTest : BaseViewModelTest() { } private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) private val authRepository: AuthRepository = mockk { + every { specialCircumstance } returns null + every { specialCircumstance = null } just runs every { userStateFlow } returns mutableUserStateFlow } private val environmentRepository: EnvironmentRepository = mockk { @@ -108,8 +110,9 @@ class AddSendViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `SaveClick with createSend success should emit NavigateBack and ShowShareSheet`() = + fun `SaveClick with createSend success should emit NavigateBack and ShowShareSheet when not an external shared`() = runTest { val viewState = DEFAULT_VIEW_STATE.copy( common = DEFAULT_COMMON_STATE.copy(name = "input"), @@ -137,6 +140,76 @@ class AddSendViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `SaveClick with createSend success should copy the send URL to the clipboard and emit NavigateBack`() = + runTest { + val viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy(name = "input"), + ) + val initialState = DEFAULT_STATE.copy( + shouldFinishOnComplete = false, + isShared = true, + viewState = viewState, + ) + val mockSendView = mockk() + every { viewState.toSendView(clock) } returns mockSendView + val sendUrl = "www.test.com/send/test" + val resultSendView = mockk { + every { toSendUrl(DEFAULT_ENVIRONMENT_URL) } returns sendUrl + } + coEvery { + vaultRepository.createSend(sendView = mockSendView, fileUri = null) + } returns CreateSendResult.Success(sendView = resultSendView) + val viewModel = createViewModel(initialState) + + viewModel.eventFlow.test { + viewModel.trySendAction(AddSendAction.SaveClick) + assertEquals(AddSendEvent.NavigateBack, awaitItem()) + } + assertEquals(initialState, viewModel.stateFlow.value) + coVerify(exactly = 1) { + vaultRepository.createSend(sendView = mockSendView, fileUri = null) + authRepository.specialCircumstance = null + clipboardManager.setText(sendUrl) + } + } + + @Suppress("MaxLineLength") + @Test + fun `SaveClick with createSend success should copy the send URL to the clipboard and emit ExitApp`() = + runTest { + val viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy(name = "input"), + ) + val initialState = DEFAULT_STATE.copy( + shouldFinishOnComplete = true, + isShared = true, + viewState = viewState, + ) + val mockSendView = mockk() + every { viewState.toSendView(clock) } returns mockSendView + val sendUrl = "www.test.com/send/test" + val resultSendView = mockk { + every { toSendUrl(DEFAULT_ENVIRONMENT_URL) } returns sendUrl + } + coEvery { + vaultRepository.createSend(sendView = mockSendView, fileUri = null) + } returns CreateSendResult.Success(sendView = resultSendView) + val viewModel = createViewModel(initialState) + + viewModel.eventFlow.test { + viewModel.trySendAction(AddSendAction.SaveClick) + assertEquals(AddSendEvent.ExitApp, awaitItem()) + } + assertEquals(initialState, viewModel.stateFlow.value) + coVerify(exactly = 1) { + vaultRepository.createSend(sendView = mockSendView, fileUri = null) + authRepository.specialCircumstance = null + clipboardManager.setText(sendUrl) + } + } + @Test fun `SaveClick with createSend failure should show error dialog`() = runTest { val viewState = DEFAULT_VIEW_STATE.copy( @@ -915,6 +988,8 @@ class AddSendViewModelTest : BaseViewModelTest() { addSendType = AddSendType.AddItem, viewState = DEFAULT_VIEW_STATE, dialogState = null, + shouldFinishOnComplete = false, + isShared = false, isPremiumUser = false, baseWebSendUrl = DEFAULT_ENVIRONMENT_URL, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SpecialCircumstanceExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SpecialCircumstanceExtensionsTest.kt new file mode 100644 index 0000000000..60fa1f8bfb --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/SpecialCircumstanceExtensionsTest.kt @@ -0,0 +1,135 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util + +import android.net.Uri +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState +import io.mockk.mockk +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 + +class SpecialCircumstanceExtensionsTest { + + @Test + fun `toSendType with TextSend should return Text SendType with correct text`() { + val text = "Share Text" + val expected = AddSendState.ViewState.Content.SendType.Text( + input = text, + isHideByDefaultChecked = false, + ) + val specialCircumstance = UserState.SpecialCircumstance.ShareNewSend( + data = IntentManager.ShareData.TextSend( + subject = "", + text = text, + ), + shouldFinishWhenComplete = false, + ) + + val result = specialCircumstance.toSendType() + + assertEquals(expected, result) + } + + @Test + fun `toSendType with FileSend should return File SendType with correct data`() { + val uri = mockk() + val fileName = "Share Name" + val sizeBytes = 100L + val expected = AddSendState.ViewState.Content.SendType.File( + uri = uri, + name = fileName, + sizeBytes = sizeBytes, + displaySize = null, + ) + val specialCircumstance = UserState.SpecialCircumstance.ShareNewSend( + data = IntentManager.ShareData.FileSend( + fileData = IntentManager.FileData( + fileName = fileName, + uri = uri, + sizeBytes = sizeBytes, + ), + ), + shouldFinishWhenComplete = false, + ) + + val result = specialCircumstance.toSendType() + + assertEquals(expected, result) + } + + @Test + fun `toSendType with null SpecialCircumstance should return null`() { + val specialCircumstance: UserState.SpecialCircumstance? = null + assertNull(specialCircumstance.toSendType()) + } + + @Test + fun `toSendName with TextSend should return subject`() { + val subject = "Subject" + val specialCircumstance = UserState.SpecialCircumstance.ShareNewSend( + data = IntentManager.ShareData.TextSend( + subject = subject, + text = "", + ), + shouldFinishWhenComplete = false, + ) + + val result = specialCircumstance.toSendName() + + assertEquals(subject, result) + } + + @Test + fun `toSendName with FileSend should return file name`() { + val fileName = "File Name" + val specialCircumstance = UserState.SpecialCircumstance.ShareNewSend( + data = IntentManager.ShareData.FileSend( + fileData = IntentManager.FileData( + fileName = fileName, + uri = mockk(), + sizeBytes = 0L, + ), + ), + shouldFinishWhenComplete = false, + ) + + val result = specialCircumstance.toSendName() + + assertEquals(fileName, result) + } + + @Test + fun `toSendName with null SpecialCircumstance should return null`() { + val specialCircumstance: UserState.SpecialCircumstance? = null + assertNull(specialCircumstance.toSendName()) + } + + @Suppress("MaxLineLength") + @Test + fun `shouldFinishOnComplete with ShareNewSend shouldFinishWhenComplete true should return true`() { + val specialCircumstance = UserState.SpecialCircumstance.ShareNewSend( + data = mockk(), + shouldFinishWhenComplete = true, + ) + assertTrue(specialCircumstance.shouldFinishOnComplete()) + } + + @Suppress("MaxLineLength") + @Test + fun `shouldFinishOnComplete with ShareNewSend shouldFinishWhenComplete false should return false`() { + val specialCircumstance = UserState.SpecialCircumstance.ShareNewSend( + data = mockk(), + shouldFinishWhenComplete = false, + ) + assertFalse(specialCircumstance.shouldFinishOnComplete()) + } + + @Test + fun `shouldFinishOnComplete with null SpecialCircumstance should return false`() { + val specialCircumstance: UserState.SpecialCircumstance? = null + assertFalse(specialCircumstance.shouldFinishOnComplete()) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 186b8710b5..878d05bb10 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -22,7 +22,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary -import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed import com.x8bit.bitwarden.ui.util.assertNoDialogExists @@ -61,7 +61,7 @@ class VaultScreenTest : BaseComposeTest() { private var onNavigateToVaultItemListingType: VaultItemListingType? = null private var onDimBottomNavBarRequestCalled = false private var onNavigateToVerificationCodeScreen = false - private val intentManager = mockk(relaxed = true) + private val exitManager = mockk(relaxed = true) private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -81,7 +81,7 @@ class VaultScreenTest : BaseComposeTest() { onNavigateToVaultItemListingScreen = { onNavigateToVaultItemListingType = it }, onDimBottomNavBarRequest = { onDimBottomNavBarRequestCalled = true }, onNavigateToVerificationCodeScreen = { onNavigateToVerificationCodeScreen = true }, - intentManager = intentManager, + exitManager = exitManager, ) } } @@ -646,9 +646,9 @@ class VaultScreenTest : BaseComposeTest() { } @Test - fun `NavigateOutOfApp event should call exitApplication on the IntentManager`() { + fun `NavigateOutOfApp event should call exitApplication on the ExitManager`() { mutableEventFlow.tryEmit(VaultEvent.NavigateOutOfApp) - verify { intentManager.exitApplication() } + verify { exitManager.exitApplication() } } @Test