From 10bf584c906d3357503046038a7df8a1878a8488 Mon Sep 17 00:00:00 2001 From: David Perez Date: Tue, 16 Jan 2024 16:25:59 -0600 Subject: [PATCH] Add initial file chooser (#639) --- app/src/main/AndroidManifest.xml | 16 ++ .../platform/manager/intent/IntentManager.kt | 31 ++++ .../manager/intent/IntentManagerImpl.kt | 138 ++++++++++++++++++ .../feature/send/addsend/AddSendContent.kt | 15 +- .../feature/send/addsend/AddSendScreen.kt | 17 +++ .../feature/send/addsend/AddSendViewModel.kt | 28 +++- .../send/addsend/handlers/AddSendHandlers.kt | 4 +- app/src/main/res/xml/file_paths.xml | 9 ++ .../feature/send/addsend/AddSendScreenTest.kt | 35 ++++- .../send/addsend/AddSendViewModelTest.kt | 19 ++- 10 files changed, 295 insertions(+), 17 deletions(-) create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03cd10c6d..f2ee6a904 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,6 +46,16 @@ + + + + + + + + + + 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 a5c70c6a7..88d0d3adc 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 @@ -2,6 +2,9 @@ package com.x8bit.bitwarden.ui.platform.manager.intent import android.content.Intent import android.net.Uri +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.result.ActivityResult +import androidx.compose.runtime.Composable /** * A manager class for simplifying the handling of Android Intents within a given context. @@ -28,8 +31,36 @@ interface IntentManager { */ fun launchUri(uri: Uri) + /** + * Start an activity using the provided [Intent] and provides a callback, via [onResult], for + * retrieving the [ActivityResult]. + */ + @Composable + fun launchActivityForResult( + onResult: (ActivityResult) -> Unit, + ): ManagedActivityResultLauncher + /** * Launches the share sheet with the given [text]. */ fun shareText(text: String) + + /** + * Processes the [activityResult] and attempts to get the relevant file data from it. + */ + fun getFileDataFromIntent(activityResult: ActivityResult): FileData? + + /** + * Creates an intent for choosing a file saved to disk. + */ + fun createFileChooserIntent(withCameraIntents: Boolean): Intent + + /** + * Represents file information. + */ + data class FileData( + val fileName: String, + val uri: Uri, + val sizeBytes: Long, + ) } 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 cc2990761..f5a8a9348 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 @@ -1,18 +1,53 @@ package com.x8bit.bitwarden.ui.platform.manager.intent +import android.app.Activity +import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.provider.MediaStore +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.runtime.Composable +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.x8bit.bitwarden.BuildConfig +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern +import java.io.File +import java.time.Clock + +/** + * The authority used for pulling in photos from the camera. + * + * Note: This must match the file provider authority in the manifest. + */ +private const val FILE_PROVIDER_AUTHORITY: String = "${BuildConfig.APPLICATION_ID}.fileprovider" + +/** + * Temporary file name for a camera image. + */ +private const val TEMP_CAMERA_IMAGE_NAME: String = "temp_camera_image.jpg" + +/** + * This directory must also be declared in file_paths.xml + */ +private const val TEMP_CAMERA_IMAGE_DIR: String = "camera_temp" /** * The default implementation of the [IntentManager] for simplifying the handling of Android * Intents within a given context. */ +@Suppress("TooManyFunctions") @OmitFromCoverage class IntentManagerImpl( private val context: Context, + private val clock: Clock = Clock.systemDefaultZone(), ) : IntentManager { override fun exitApplication() { @@ -28,6 +63,15 @@ class IntentManagerImpl( context.startActivity(intent) } + @Composable + override fun launchActivityForResult( + onResult: (ActivityResult) -> Unit, + ): ManagedActivityResultLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = onResult, + ) + override fun startCustomTabsActivity(uri: Uri) { CustomTabsIntent .Builder() @@ -51,4 +95,98 @@ class IntentManagerImpl( } startActivity(Intent.createChooser(sendIntent, null)) } + + override fun getFileDataFromIntent(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 createFileChooserIntent(withCameraIntents: Boolean): Intent { + val chooserIntent = Intent.createChooser( + Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*"), + ContextCompat.getString(context, R.string.file_source), + ) + + if (withCameraIntents) { + val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR) + val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME) + if (!file.exists()) { + file.parentFile?.mkdirs() + file.createNewFile() + } + val outputFileUri = FileProvider.getUriForFile( + context, + FILE_PROVIDER_AUTHORITY, + file, + ) + + chooserIntent.putExtra( + Intent.EXTRA_INITIAL_INTENTS, + getCameraIntents(outputFileUri).toTypedArray(), + ) + } + + return chooserIntent + } + + private fun getCameraFileData(): IntentManager.FileData { + val tmpDir = File(context.filesDir, TEMP_CAMERA_IMAGE_DIR) + val file = File(tmpDir, TEMP_CAMERA_IMAGE_NAME) + val uri = FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, file) + val fileName = "photo_${clock.instant().toFormattedPattern(pattern = "yyyyMMddHHmmss")}.jpg" + return IntentManager.FileData( + fileName = fileName, + uri = uri, + sizeBytes = file.length(), + ) + } + + private fun getLocalFileData(uri: Uri): IntentManager.FileData? = + context + .contentResolver + .query( + uri, + arrayOf( + MediaStore.MediaColumns.DISPLAY_NAME, + MediaStore.MediaColumns.SIZE, + ), + null, + null, + null, + ) + ?.use { cursor -> + if (!cursor.moveToFirst()) return@use null + val fileName = cursor + .getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME) + .takeIf { it >= 0 } + ?.let { cursor.getString(it) } + val fileSize = cursor + .getColumnIndex(MediaStore.MediaColumns.SIZE) + .takeIf { it >= 0 } + ?.let { cursor.getLong(it) } + if (fileName == null || fileSize == null) return@use null + IntentManager.FileData( + fileName = fileName, + uri = uri, + sizeBytes = fileSize, + ) + } + + private fun getCameraIntents(outputUri: Uri): List { + val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + return context + .packageManager + .queryIntentActivities(captureIntent, PackageManager.MATCH_ALL) + .map { + val packageName = it.activityInfo.packageName + Intent(captureIntent).apply { + component = ComponentName(packageName, it.activityInfo.name) + setPackage(packageName) + putExtra(MediaStore.EXTRA_OUTPUT, outputUri) + } + } + } } 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 71f963800..7efa7f26d 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 @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend +import android.Manifest import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -42,6 +43,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.components.SegmentedButtonState +import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers @@ -54,8 +56,13 @@ fun AddSendContent( state: AddSendState.ViewState.Content, isAddMode: Boolean, addSendHandlers: AddSendHandlers, + permissionsManager: PermissionsManager, modifier: Modifier = Modifier, ) { + val chooseFileCameraPermissionLauncher = permissionsManager.getLauncher { isGranted -> + addSendHandlers.onChooseFileClick(isGranted) + } + Column( modifier = modifier .verticalScroll(rememberScrollState()), @@ -118,7 +125,13 @@ fun AddSendContent( Spacer(modifier = Modifier.height(8.dp)) BitwardenFilledTonalButton( label = stringResource(id = R.string.choose_file), - onClick = addSendHandlers.onChooseFileCLick, + onClick = { + if (permissionsManager.checkPermission(Manifest.permission.CAMERA)) { + addSendHandlers.onChooseFileClick(true) + } else { + chooseFileCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), 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 63204a1bd..945b9b70c 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 @@ -35,7 +35,9 @@ 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.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager +import com.x8bit.bitwarden.ui.platform.theme.LocalPermissionsManager import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers @@ -48,6 +50,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandler fun AddSendScreen( viewModel: AddSendViewModel = hiltViewModel(), intentManager: IntentManager = LocalIntentManager.current, + permissionsManager: PermissionsManager = LocalPermissionsManager.current, onNavigateBack: () -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -55,9 +58,22 @@ fun AddSendScreen( val context = LocalContext.current val resources = context.resources + val fileChooserLauncher = intentManager.launchActivityForResult { activityResult -> + intentManager.getFileDataFromIntent(activityResult)?.let { + viewModel.trySendAction(AddSendAction.FileChoose(it)) + } + } + EventsEffect(viewModel = viewModel) { event -> when (event) { is AddSendEvent.NavigateBack -> onNavigateBack() + + is AddSendEvent.ShowChooserSheet -> { + fileChooserLauncher.launch( + intentManager.createFileChooserIntent(event.withCameraOption), + ) + } + is AddSendEvent.ShowShareSheet -> { intentManager.shareText(event.message) } @@ -159,6 +175,7 @@ fun AddSendScreen( state = viewState, isAddMode = state.isAddMode, 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 f214e10af..3b3112938 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 @@ -21,6 +21,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text 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.toSendView import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toViewState @@ -119,6 +120,7 @@ class AddSendViewModel @Inject constructor( override fun handleAction(action: AddSendAction): Unit = when (action) { AddSendAction.CopyLinkClick -> handleCopyLinkClick() AddSendAction.DeleteClick -> handleDeleteClick() + is AddSendAction.FileChoose -> handeFileChose(action) AddSendAction.RemovePasswordClick -> handleRemovePasswordClick() AddSendAction.ShareLinkClick -> handleShareLinkClick() is AddSendAction.CloseClick -> handleCloseClick() @@ -129,7 +131,7 @@ class AddSendViewModel @Inject constructor( is AddSendAction.SaveClick -> handleSaveClick() is AddSendAction.FileTypeClick -> handleFileTypeClick() is AddSendAction.TextTypeClick -> handleTextTypeClick() - is AddSendAction.ChooseFileClick -> handleChooseFileClick() + is AddSendAction.ChooseFileClick -> handleChooseFileClick(action) is AddSendAction.NameChange -> handleNameChange(action) is AddSendAction.MaxAccessCountChange -> handleMaxAccessCountChange(action) is AddSendAction.TextChange -> handleTextChange(action) @@ -354,6 +356,11 @@ class AddSendViewModel @Inject constructor( } } + private fun handeFileChose(action: AddSendAction.FileChoose) { + // TODO: Process the chosen file (BIT-493) + sendEvent(AddSendEvent.ShowToast("Not Yet Implemented".asText())) + } + private fun handleRemovePasswordClick() { onEdit { mutableStateFlow.update { @@ -511,9 +518,8 @@ class AddSendViewModel @Inject constructor( } } - private fun handleChooseFileClick() { - // TODO: allow for file upload: BIT-1085 - sendEvent(AddSendEvent.ShowToast("Not Implemented: File Upload".asText())) + private fun handleChooseFileClick(action: AddSendAction.ChooseFileClick) { + sendEvent(AddSendEvent.ShowChooserSheet(action.isCameraPermissionGranted)) } private fun handleMaxAccessCountChange(action: AddSendAction.MaxAccessCountChange) { @@ -721,6 +727,11 @@ sealed class AddSendEvent { */ data object NavigateBack : AddSendEvent() + /** + * Show file chooser sheet. + */ + data class ShowChooserSheet(val withCameraOption: Boolean) : AddSendEvent() + /** * Show share sheet. */ @@ -737,6 +748,11 @@ sealed class AddSendEvent { */ sealed class AddSendAction { + /** + * User has chosen a file to be part of the send. + */ + data class FileChoose(val fileData: IntentManager.FileData) : AddSendAction() + /** * User clicked the remove password button. */ @@ -805,7 +821,9 @@ sealed class AddSendAction { /** * User clicked the choose file button. */ - data object ChooseFileClick : AddSendAction() + data class ChooseFileClick( + val isCameraPermissionGranted: Boolean, + ) : AddSendAction() /** * User toggled the "hide text by default" toggle. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt index dac92be9f..5a66d1152 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/handlers/AddSendHandlers.kt @@ -12,7 +12,7 @@ data class AddSendHandlers( val onNamChange: (String) -> Unit, val onFileTypeSelect: () -> Unit, val onTextTypeSelect: () -> Unit, - val onChooseFileCLick: () -> Unit, + val onChooseFileClick: (hasPermission: Boolean) -> Unit, val onTextChange: (String) -> Unit, val onIsHideByDefaultToggle: (Boolean) -> Unit, val onMaxAccessCountChange: (Int) -> Unit, @@ -36,7 +36,7 @@ data class AddSendHandlers( onNamChange = { viewModel.trySendAction(AddSendAction.NameChange(it)) }, onFileTypeSelect = { viewModel.trySendAction(AddSendAction.FileTypeClick) }, onTextTypeSelect = { viewModel.trySendAction(AddSendAction.TextTypeClick) }, - onChooseFileCLick = { viewModel.trySendAction(AddSendAction.ChooseFileClick) }, + onChooseFileClick = { viewModel.trySendAction(AddSendAction.ChooseFileClick(it)) }, onTextChange = { viewModel.trySendAction(AddSendAction.TextChange(it)) }, onIsHideByDefaultToggle = { viewModel.trySendAction(AddSendAction.HideByDefaultToggle(it)) diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 000000000..21f70570f --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,9 @@ + + + + + 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 ab93e8cd6..fbe7a71ba 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 @@ -24,6 +24,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.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType import com.x8bit.bitwarden.ui.util.isEditableText import com.x8bit.bitwarden.ui.util.isProgressBar @@ -44,7 +45,8 @@ class AddSendScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false - private val intentManager: IntentManager = mockk { + private val permissionsManager = FakePermissionManager() + private val intentManager: IntentManager = mockk(relaxed = true) { every { shareText(any()) } just runs } private val mutableEventFlow = bufferedMutableSharedFlow() @@ -60,6 +62,7 @@ class AddSendScreenTest : BaseComposeTest() { AddSendScreen( viewModel = viewModel, intentManager = intentManager, + permissionsManager = permissionsManager, onNavigateBack = { onNavigateBackCalled = true }, ) } @@ -329,7 +332,8 @@ class AddSendScreenTest : BaseComposeTest() { } @Test - fun `Choose file button click should send ChooseFileClick`() { + fun `Choose file button click with permission should send ChooseFileClick`() { + permissionsManager.checkPermissionResult = true mutableStateFlow.value = DEFAULT_STATE.copy( viewState = DEFAULT_VIEW_STATE.copy( selectedType = AddSendState.ViewState.Content.SendType.File, @@ -339,7 +343,32 @@ class AddSendScreenTest : BaseComposeTest() { .onNodeWithText("Choose file") .performScrollTo() .performClick() - verify { viewModel.trySendAction(AddSendAction.ChooseFileClick) } + verify { + viewModel.trySendAction( + AddSendAction.ChooseFileClick(isCameraPermissionGranted = true), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `Choose file button click without permission should request permission and send ChooseFileClick`() { + permissionsManager.checkPermissionResult = false + permissionsManager.getPermissionsResult = false + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = DEFAULT_VIEW_STATE.copy( + selectedType = AddSendState.ViewState.Content.SendType.File, + ), + ) + composeTestRule + .onNodeWithText("Choose file") + .performScrollTo() + .performClick() + verify { + viewModel.trySendAction( + AddSendAction.ChooseFileClick(isCameraPermissionGranted = false), + ) + } } @Test 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 8ca20747d..ebd656b34 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 @@ -581,14 +581,21 @@ class AddSendViewModelTest : BaseViewModelTest() { } @Test - fun `ChooseFileClick should emit ShowToast`() = runTest { + fun `FileChose should emit ShowToast`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { - viewModel.trySendAction(AddSendAction.ChooseFileClick) - assertEquals( - AddSendEvent.ShowToast("Not Implemented: File Upload".asText()), - awaitItem(), - ) + viewModel.trySendAction(AddSendAction.FileChoose(fileData = mockk())) + assertEquals(AddSendEvent.ShowToast("Not Yet Implemented".asText()), awaitItem()) + } + } + + @Test + fun `ChooseFileClick should emit ShowToast`() = runTest { + val arePermissionsGranted = true + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AddSendAction.ChooseFileClick(arePermissionsGranted)) + assertEquals(AddSendEvent.ShowChooserSheet(arePermissionsGranted), awaitItem()) } }