Add initial file chooser (#639)

This commit is contained in:
David Perez 2024-01-16 16:25:59 -06:00 committed by Álison Fernandes
parent ebd9628b02
commit 10bf584c90
10 changed files with 295 additions and 17 deletions

View file

@ -46,6 +46,16 @@
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".data.autofill.BitwardenAutofillService"
android:exported="true"
@ -69,4 +79,10 @@
</service>
</application>
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
</queries>
</manifest>

View file

@ -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<Intent, ActivityResult>
/**
* 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,
)
}

View file

@ -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<Intent, ActivityResult> =
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<Intent> {
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)
}
}
}
}

View file

@ -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),

View file

@ -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,
)

View file

@ -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.

View file

@ -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))

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="cache"
path="." />
<files-path
name="temp_camera_images"
path="camera_temp/" />
</paths>

View file

@ -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<AddSendEvent>()
@ -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

View file

@ -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())
}
}