Bug 1896848 - Fallback to Android Photo Picker when no permissions granted. r=android-reviewers,jonalmeida

Differential Revision: https://phabricator.services.mozilla.com/D214094
This commit is contained in:
mcarare 2024-07-03 19:53:58 +00:00
parent 8dc31f9fda
commit 8f68d07560
10 changed files with 1008 additions and 257 deletions

View File

@ -8,6 +8,7 @@ import android.content.Context
import android.net.Uri
import mozilla.components.concept.engine.prompt.PromptRequest.Authentication.Level
import mozilla.components.concept.engine.prompt.PromptRequest.Authentication.Method
import mozilla.components.concept.engine.prompt.PromptRequest.File
import mozilla.components.concept.engine.prompt.PromptRequest.TimeSelection.Type
import mozilla.components.concept.identitycredential.Account
import mozilla.components.concept.identitycredential.Provider
@ -443,3 +444,21 @@ sealed class PromptRequest(
val onDismiss: () -> Unit
}
}
/**
* Checks if the current [PromptRequest] is a request to pick an image.
*
* @return true if the current request is a request for selecting one or more images, false otherwise.
*/
fun PromptRequest?.isPhotoRequest(): Boolean {
return this is File && mimeTypes.any { it.startsWith("image/") }
}
/**
* Checks if the current [PromptRequest] is a request to pick a video.
*
* @return true if the current request is a request for selecting one or more videos, false otherwise.
*/
fun PromptRequest?.isVideoRequest(): Boolean {
return this is File && mimeTypes.any { it.startsWith("video/") }
}

View File

@ -6,6 +6,7 @@ package mozilla.components.feature.prompts
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.Companion.PRIVATE
import androidx.core.view.isVisible
@ -76,6 +77,7 @@ import mozilla.components.feature.prompts.facts.emitPromptDismissedFact
import mozilla.components.feature.prompts.facts.emitPromptDisplayedFact
import mozilla.components.feature.prompts.facts.emitSuccessfulAddressAutofillFormDetectedFact
import mozilla.components.feature.prompts.facts.emitSuccessfulCreditCardAutofillFormDetectedFact
import mozilla.components.feature.prompts.file.AndroidPhotoPicker
import mozilla.components.feature.prompts.file.FilePicker
import mozilla.components.feature.prompts.file.FileUploadsDirCleaner
import mozilla.components.feature.prompts.identitycredential.DialogColors
@ -203,6 +205,7 @@ class PromptFeature private constructor(
private val addressDelegate: AddressDelegate = DefaultAddressDelegate(),
private val fileUploadsDirCleaner: FileUploadsDirCleaner,
onNeedToRequestPermissions: OnNeedToRequestPermissions,
androidPhotoPicker: AndroidPhotoPicker?,
) : LifecycleAwareFeature,
PermissionsFeature,
Prompter,
@ -258,6 +261,7 @@ class PromptFeature private constructor(
addressDelegate: AddressDelegate = DefaultAddressDelegate(),
fileUploadsDirCleaner: FileUploadsDirCleaner,
onNeedToRequestPermissions: OnNeedToRequestPermissions,
androidPhotoPicker: AndroidPhotoPicker? = null,
) : this(
container = PromptContainer.Activity(activity),
store = store,
@ -286,6 +290,7 @@ class PromptFeature private constructor(
passwordGeneratorColorsProvider = passwordGeneratorColorsProvider,
creditCardDelegate = creditCardDelegate,
addressDelegate = addressDelegate,
androidPhotoPicker = androidPhotoPicker,
)
constructor(
@ -314,6 +319,7 @@ class PromptFeature private constructor(
creditCardDelegate: CreditCardDelegate = object : CreditCardDelegate {},
addressDelegate: AddressDelegate = DefaultAddressDelegate(),
fileUploadsDirCleaner: FileUploadsDirCleaner,
androidPhotoPicker: AndroidPhotoPicker? = null,
onNeedToRequestPermissions: OnNeedToRequestPermissions,
) : this(
container = PromptContainer.Fragment(fragment),
@ -330,8 +336,6 @@ class PromptFeature private constructor(
isCreditCardAutofillEnabled = isCreditCardAutofillEnabled,
isAddressAutofillEnabled = isAddressAutofillEnabled,
loginExceptionStorage = loginExceptionStorage,
fileUploadsDirCleaner = fileUploadsDirCleaner,
onNeedToRequestPermissions = onNeedToRequestPermissions,
loginDelegate = loginDelegate,
suggestStrongPasswordDelegate = suggestStrongPasswordDelegate,
isSuggestStrongPasswordEnabled = isSuggestStrongPasswordEnabled,
@ -341,10 +345,21 @@ class PromptFeature private constructor(
onSavedGeneratedPassword = onSavedGeneratedPassword,
creditCardDelegate = creditCardDelegate,
addressDelegate = addressDelegate,
fileUploadsDirCleaner = fileUploadsDirCleaner,
onNeedToRequestPermissions = onNeedToRequestPermissions,
androidPhotoPicker = androidPhotoPicker,
)
private val filePicker =
FilePicker(container, store, customTabId, fileUploadsDirCleaner, onNeedToRequestPermissions)
@VisibleForTesting
// var for testing purposes
internal var filePicker = FilePicker(
container,
store,
customTabId,
fileUploadsDirCleaner,
androidPhotoPicker,
onNeedToRequestPermissions,
)
@VisibleForTesting(otherwise = PRIVATE)
internal var loginPicker =
@ -1206,6 +1221,16 @@ class PromptFeature private constructor(
return result
}
/**
* Handles the result received from the Android photo picker.
*
* @param listOf An array of [Uri] objects representing the selected photos.
*/
fun onAndroidPhotoPickerResult(uriList: Array<Uri>) {
filePicker.onAndroidPhotoPickerResult(uriList)
}
companion object {
// The PIN request code
const val PIN_REQUEST = 303

View File

@ -0,0 +1,72 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.feature.prompts.file
import android.content.Context
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import mozilla.components.feature.prompts.PromptFeature
/**
* Provides functionality for picking photos from the device's gallery using native picker.
*
* @property context The application [Context].
* @property singleMediaPicker An [ActivityResultLauncher] for picking a single photo.
* @property multipleMediaPicker An [ActivityResultLauncher] for picking multiple photos.
*/
class AndroidPhotoPicker(
val context: Context,
val singleMediaPicker: ActivityResultLauncher<PickVisualMediaRequest>,
val multipleMediaPicker: ActivityResultLauncher<PickVisualMediaRequest>,
) {
internal val isPhotoPickerAvailable =
ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable(context)
companion object {
/**
* Registers a photo picker activity launcher in single-select mode.
* Note that you must call singleMediaPicker before the fragment is created.
*
* @param getFragment A function that returns the [Fragment] which hosts the file picker.
* @param getPromptsFeature A function that returns the [PromptFeature]
* that handles the result of the photo picker.
* @return An [ActivityResultLauncher] for picking a single photo.
*/
fun singleMediaPicker(
getFragment: () -> Fragment,
getPromptsFeature: () -> PromptFeature?,
): ActivityResultLauncher<PickVisualMediaRequest> {
return getFragment.invoke()
.registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
uri?.let {
getPromptsFeature.invoke()?.onAndroidPhotoPickerResult(arrayOf(uri))
}
}
}
/**
* Registers a photo picker activity launcher in multi-select mode.
* Note that you must call multipleMediaPicker before the fragment is created.
*
* @param getFragment A function that returns the [Fragment] which hosts the file picker.
* @param getPromptsFeature A function that returns the [PromptFeature]
* that handles the result of the photo picker.
* @return An [ActivityResultLauncher] for picking multiple photos.
*/
fun multipleMediaPicker(
getFragment: () -> Fragment,
getPromptsFeature: () -> PromptFeature?,
): ActivityResultLauncher<PickVisualMediaRequest> {
return getFragment.invoke()
.registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia()) { uriList ->
uriList?.let {
getPromptsFeature.invoke()?.onAndroidPhotoPickerResult(uriList.toTypedArray())
}
}
}
}
}

View File

@ -12,6 +12,9 @@ import android.content.Intent.EXTRA_INITIAL_INTENTS
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.provider.MediaStore.EXTRA_OUTPUT
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia.VisualMediaType
import androidx.annotation.VisibleForTesting
import androidx.annotation.VisibleForTesting.Companion.PRIVATE
import androidx.fragment.app.Fragment
@ -19,6 +22,8 @@ import mozilla.components.browser.state.selector.findCustomTabOrSelectedTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.prompt.PromptRequest
import mozilla.components.concept.engine.prompt.PromptRequest.File
import mozilla.components.concept.engine.prompt.isPhotoRequest
import mozilla.components.concept.engine.prompt.isVideoRequest
import mozilla.components.feature.prompts.PromptContainer
import mozilla.components.feature.prompts.consumePromptFrom
import mozilla.components.support.base.feature.OnNeedToRequestPermissions
@ -51,6 +56,8 @@ internal class FilePicker(
private val store: BrowserStore,
private var sessionId: String? = null,
private var fileUploadsDirCleaner: FileUploadsDirCleaner,
@get:VisibleForTesting
internal var androidPhotoPicker: AndroidPhotoPicker? = null,
override val onNeedToRequestPermissions: OnNeedToRequestPermissions,
) : PermissionsFeature {
@ -218,11 +225,11 @@ internal class FilePicker(
if (grantResults.isNotEmpty() && grantResults.any { it == PERMISSION_GRANTED }) {
// at least one permission was granted
onPermissionsGranted(currentRequest as File)
currentRequest = null
} else {
// all permissions were denied, either when requested or already permanently denied
onPermissionsDenied()
}
currentRequest = null
}
/**
@ -245,8 +252,68 @@ internal class FilePicker(
*/
@VisibleForTesting
internal fun onPermissionsDenied() {
// Nothing left to do. Consume / cleanup the requests.
if (canUseAndroidPhotoPicker()) {
launchAndroidPhotoPicker()
} else {
// Nothing left to do. Consume / cleanup the requests.
dismissRequest()
}
}
@VisibleForTesting
internal fun canUseAndroidPhotoPicker(): Boolean {
return androidPhotoPicker != null &&
isPhotoOrVideoRequest(currentRequest) &&
androidPhotoPicker?.isPhotoPickerAvailable == true
}
@VisibleForTesting
internal fun isPhotoOrVideoRequest(request: PromptRequest?): Boolean {
return request.isPhotoRequest() || request.isVideoRequest()
}
@VisibleForTesting
internal fun getVisualMediaType(request: PromptRequest?): VisualMediaType {
val mimeTypes = (request as? File)?.mimeTypes ?: emptyArray()
val isPhotoRequest = request.isPhotoRequest()
val isVideoRequest = request.isVideoRequest()
if (mimeTypes.size == 1 && (isPhotoRequest || isVideoRequest)) {
return ActivityResultContracts.PickVisualMedia.SingleMimeType(
mimeTypes[0],
)
}
return when {
isPhotoRequest && isVideoRequest -> ActivityResultContracts.PickVisualMedia.ImageAndVideo
isPhotoRequest -> ActivityResultContracts.PickVisualMedia.ImageOnly
isVideoRequest -> ActivityResultContracts.PickVisualMedia.VideoOnly
else -> throw IllegalStateException(
"Unexpected state: getVisualMediaType should only be called if isPhotoOrVideoRequest is true",
)
}
}
private fun launchAndroidPhotoPicker() {
if ((currentRequest as File).isMultipleFilesSelection) {
androidPhotoPicker?.multipleMediaPicker?.launch(
PickVisualMediaRequest(
getVisualMediaType(currentRequest),
),
)
} else {
androidPhotoPicker?.singleMediaPicker?.launch(
PickVisualMediaRequest(
getVisualMediaType(currentRequest),
),
)
}
}
private fun dismissRequest() {
store.consumePromptFrom<File>(sessionId) { request ->
currentRequest = null
request.onDismiss()
}
}
@ -298,6 +365,16 @@ internal class FilePicker(
fileUploadsDirCleaner.enqueueForCleanup(fileName)
}
fun onAndroidPhotoPickerResult(uriList: Array<Uri>) {
if (uriList.size == 1) {
(currentRequest as? File)?.onSingleFileSelected?.let { it(container.context, uriList[0]) }
} else {
(currentRequest as? File)?.onMultipleFilesSelected?.let { it(container.context, uriList) }
}
dismissRequest()
}
companion object {
const val FILE_PICKER_ACTIVITY_REQUEST_CODE = 7113
}

View File

@ -10,8 +10,12 @@ import mozilla.components.concept.engine.prompt.PromptRequest.Confirm
import mozilla.components.concept.engine.prompt.PromptRequest.Popup
import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice
import mozilla.components.concept.engine.prompt.PromptRequest.TextPrompt
import mozilla.components.concept.engine.prompt.isPhotoRequest
import mozilla.components.concept.engine.prompt.isVideoRequest
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.reflect.KClass
@ -52,4 +56,52 @@ class PromptRequestTest {
assertEquals(0, invocations)
}
@Test
fun `isPhotoRequest returns true when mime types contain image`() {
val request = PromptRequest.File(
arrayOf("image/png"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
assertTrue(request.isPhotoRequest())
}
@Test
fun `isPhotoRequest returns false when mime types do not contain image`() {
val request = PromptRequest.File(
arrayOf("video/mp4"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
assertFalse(request.isPhotoRequest())
}
@Test
fun `isVideoRequest returns true when mime types contain video`() {
val request = PromptRequest.File(
arrayOf("video/mp4"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
assertTrue(request.isVideoRequest())
}
@Test
fun `isVideoRequest returns false when mime types do not contain video`() {
val request = PromptRequest.File(
arrayOf("image/png"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
assertFalse(request.isVideoRequest())
}
}

View File

@ -0,0 +1,134 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.feature.prompts.file
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.feature.prompts.PromptFeature
import mozilla.components.support.test.argumentCaptor
import mozilla.components.support.test.whenever
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class AndroidPhotoPickerTest {
private lateinit var context: Context
private lateinit var fragment: Fragment
private lateinit var packageManager: PackageManager
private lateinit var singleMediaPicker: ActivityResultLauncher<PickVisualMediaRequest>
private lateinit var multipleMediaPicker: ActivityResultLauncher<PickVisualMediaRequest>
private lateinit var promptFeature: PromptFeature
private lateinit var androidPhotoPicker: AndroidPhotoPicker
@Before
fun setup() {
context = mock()
fragment = mock()
singleMediaPicker = mock()
multipleMediaPicker = mock()
promptFeature = mock()
packageManager = mock()
whenever(
packageManager.resolveActivity(
any(Intent::class.java),
anyInt(),
),
).thenReturn(null)
whenever(
fragment.registerForActivityResult(
any<ActivityResultContracts.PickVisualMedia>(),
any(),
),
).thenReturn(mock())
whenever(context.packageManager).thenReturn(packageManager)
androidPhotoPicker = AndroidPhotoPicker(context, singleMediaPicker, multipleMediaPicker)
}
@Test
fun `isPhotoPickerAvailable returns true when photo picker is available`() {
// on Android 10 and above the system framework provided photo picker should be available
assertTrue(androidPhotoPicker.isPhotoPickerAvailable)
}
@Test
@Config(sdk = [28])
fun `isPhotoPickerAvailable returns false when photo picker is not available`() {
assertFalse(androidPhotoPicker.isPhotoPickerAvailable)
}
@Test
fun `singleMediaPicker uses a proper ActivityResultContract`() {
AndroidPhotoPicker.singleMediaPicker({ fragment }, { promptFeature })
val contractCaptor = argumentCaptor<ActivityResultContract<Intent, ActivityResult>>()
val callbackCaptor = argumentCaptor<ActivityResultCallback<ActivityResult>>()
verify(fragment).registerForActivityResult(
contractCaptor.capture(),
callbackCaptor.capture(),
)
assertTrue(contractCaptor.value is ActivityResultContracts.PickVisualMedia)
assertNotNull(callbackCaptor.value)
}
@Test
fun `multipleMediaPicker uses a proper ActivityResultContract`() {
AndroidPhotoPicker.multipleMediaPicker({ fragment }, { promptFeature })
val contractCaptor = argumentCaptor<ActivityResultContract<Intent, ActivityResult>>()
val callbackCaptor = argumentCaptor<ActivityResultCallback<ActivityResult>>()
verify(fragment).registerForActivityResult(
contractCaptor.capture(),
callbackCaptor.capture(),
)
assertTrue(contractCaptor.value is ActivityResultContracts.PickMultipleVisualMedia)
assertNotNull(callbackCaptor.value)
}
@Test
fun `singleMediaPicker returns a valid ActivityResultLauncher`() {
val launcher = AndroidPhotoPicker.singleMediaPicker(
{ fragment },
{ promptFeature },
)
assertNotNull(launcher)
}
@Test
fun `multipleMediaPicker returns a valid ActivityResultLauncher`() {
val launcher = AndroidPhotoPicker.multipleMediaPicker(
{ fragment },
{ promptFeature },
)
assertNotNull(launcher)
}
}

View File

@ -13,6 +13,7 @@ import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_DENIED
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
@ -40,6 +41,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.anyInt
import org.mockito.Mockito.doNothing
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.never
import org.mockito.Mockito.spy
@ -193,7 +195,7 @@ class FilePickerTest {
}
@Test
fun `onPermissionsDeny will call onDismiss and consume the file PromptRequest of the actual session`() {
fun `onPermissionsDeny will call onDismiss and consume the file PromptRequest of the actual session if androidPhotoPicker is null`() {
var onDismissWasCalled = false
val filePickerRequest = request.copy {
onDismissWasCalled = true
@ -324,6 +326,8 @@ class FilePickerTest {
@Test
fun `onRequestPermissionsResult with FILE_PICKER_REQUEST and PERMISSION_DENIED will call onPermissionsDeny`() {
filePicker = spy(filePicker)
doNothing().`when`(filePicker).onPermissionsDenied()
filePicker.onPermissionsResult(emptyArray(), IntArray(1) { PERMISSION_DENIED })
verify(filePicker).onPermissionsDenied()
@ -334,7 +338,12 @@ class FilePickerTest {
val permissions = setOf("PermissionA")
var permissionsRequested = emptyArray<String>()
filePicker = spy(
FilePicker(fragment, store, null, fileUploadsDirCleaner = mock()) { requested ->
FilePicker(
fragment,
store,
null,
fileUploadsDirCleaner = mock(),
) { requested ->
permissionsRequested = requested
},
)
@ -455,6 +464,177 @@ class FilePickerTest {
assertNull(captureUri)
}
@Test
fun `canUseAndroidPhotoPicker returns true when conditions are met`() {
val mockAndroidPhotoPicker = mock<AndroidPhotoPicker>()
val filePickerSpy = spy(filePicker)
filePickerSpy.currentRequest = request
filePickerSpy.androidPhotoPicker = mockAndroidPhotoPicker
whenever(filePickerSpy.isPhotoOrVideoRequest(request)).thenReturn(true)
whenever(mockAndroidPhotoPicker.isPhotoPickerAvailable).thenReturn(true)
val result = filePickerSpy.canUseAndroidPhotoPicker()
assertTrue(result)
}
@Test
fun `canUseAndroidPhotoPicker returns false when the request is not for photo or video`() {
val mockAndroidPhotoPicker = mock<AndroidPhotoPicker>()
val filePickerSpy = spy(filePicker)
filePickerSpy.currentRequest = request
filePickerSpy.androidPhotoPicker = mockAndroidPhotoPicker
whenever(filePickerSpy.isPhotoOrVideoRequest(request)).thenReturn(false)
whenever(mockAndroidPhotoPicker.isPhotoPickerAvailable).thenReturn(true)
val result = filePickerSpy.canUseAndroidPhotoPicker()
assertFalse(result)
}
@Test
fun `canUseAndroidPhotoPicker returns false when photo picker is not available`() {
val mockAndroidPhotoPicker = mock<AndroidPhotoPicker>()
val filePickerSpy = spy(filePicker)
filePickerSpy.currentRequest = request
filePickerSpy.androidPhotoPicker = mockAndroidPhotoPicker
whenever(filePickerSpy.isPhotoOrVideoRequest(request)).thenReturn(true)
whenever(mockAndroidPhotoPicker.isPhotoPickerAvailable).thenReturn(false)
val result = filePickerSpy.canUseAndroidPhotoPicker()
assertFalse(result)
}
@Test
fun `canUseAndroidPhotoPicker returns false when androidPhotoPicker is null`() {
val mockAndroidPhotoPicker = mock<AndroidPhotoPicker>()
val filePickerSpy = spy(filePicker)
filePickerSpy.currentRequest = request
filePickerSpy.androidPhotoPicker = null
whenever(filePickerSpy.isPhotoOrVideoRequest(request)).thenReturn(true)
whenever(mockAndroidPhotoPicker.isPhotoPickerAvailable).thenReturn(false)
val result = filePickerSpy.canUseAndroidPhotoPicker()
assertFalse(result)
}
@Test
fun `isPhotoOrVideoRequest returns true for image and video mime types`() {
val request = PromptRequest.File(
arrayOf("image/png", "video/mp4"),
onSingleFileSelected = noopSingle,
onMultipleFilesSelected = noopMulti,
onDismiss = {},
)
val filePickerSpy = spy(filePicker)
val result = filePickerSpy.isPhotoOrVideoRequest(request)
assertTrue(result)
}
@Test
fun `isPhotoOrVideoRequest returns false for non-image and non-video mime types`() {
val request = PromptRequest.File(
arrayOf("application/pdf"),
onSingleFileSelected = noopSingle,
onMultipleFilesSelected = noopMulti,
onDismiss = {},
)
val filePickerSpy = spy(filePicker)
val result = filePickerSpy.isPhotoOrVideoRequest(request)
assertFalse(result)
}
@Test
fun `getVisualMediaType returns SingleMimeType when mime types contain only one mime type`() {
val request = PromptRequest.File(
arrayOf("image/png"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
val result = filePicker.getVisualMediaType(request)
assertTrue(result is ActivityResultContracts.PickVisualMedia.SingleMimeType)
assertEquals(
"image/png",
(result as ActivityResultContracts.PickVisualMedia.SingleMimeType).mimeType,
)
}
@Test
fun `getVisualMediaType returns ImageAndVideo when mime types contain both image and video`() {
val request = PromptRequest.File(
arrayOf("image/png", "video/mp4"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
val result = filePicker.getVisualMediaType(request)
assertEquals(
ActivityResultContracts.PickVisualMedia.ImageAndVideo,
result,
)
}
@Test
fun `getVisualMediaType returns ImageOnly when mime types contain only image`() {
val request = PromptRequest.File(
arrayOf("image/png", "image/jpeg"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
val result = filePicker.getVisualMediaType(request)
assertEquals(ActivityResultContracts.PickVisualMedia.ImageOnly, result)
}
@Test
fun `getVisualMediaType returns VideoOnly when mime types contain only video`() {
val request = PromptRequest.File(
arrayOf("video/mp4", "video/avi"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
val result = filePicker.getVisualMediaType(request)
assertEquals(ActivityResultContracts.PickVisualMedia.VideoOnly, result)
}
@Test(expected = IllegalStateException::class)
fun `getVisualMediaType throws IllegalStateException when mime types do not contain image or video`() {
val request = PromptRequest.File(
arrayOf("application/pdf"),
onSingleFileSelected = { _, _ -> },
onMultipleFilesSelected = { _, _ -> },
onDismiss = {},
)
filePicker.getVisualMediaType(request)
}
private fun prepareSelectedSession(request: PromptRequest? = null): TabSessionState {
val promptRequest: PromptRequest = request ?: mock()
val content: ContentState = mock()
@ -469,6 +649,10 @@ class FilePickerTest {
private fun stubContext() {
val context = ApplicationProvider.getApplicationContext<Context>()
doReturn(context).`when`(fragment).context
filePicker = FilePicker(fragment, store, fileUploadsDirCleaner = fileUploadsDirCleaner) {}
filePicker = FilePicker(
fragment,
store,
fileUploadsDirCleaner = fileUploadsDirCleaner,
) {}
}
}

View File

@ -88,6 +88,7 @@ import mozilla.components.feature.prompts.PromptFeature.Companion.PIN_REQUEST
import mozilla.components.feature.prompts.address.AddressDelegate
import mozilla.components.feature.prompts.creditcard.CreditCardDelegate
import mozilla.components.feature.prompts.dialog.FullScreenNotificationDialog
import mozilla.components.feature.prompts.file.AndroidPhotoPicker
import mozilla.components.feature.prompts.identitycredential.DialogColors
import mozilla.components.feature.prompts.identitycredential.DialogColorsProvider
import mozilla.components.feature.prompts.login.LoginDelegate
@ -297,11 +298,33 @@ abstract class BaseBrowserFragment :
private lateinit var savedLoginsLauncher: ActivityResultLauncher<Intent>
// Registers a photo picker activity launcher in single-select mode.
private val singleMediaPicker =
AndroidPhotoPicker.singleMediaPicker(
{ getFragment() },
{ getPromptsFeature() },
)
// Registers a photo picker activity launcher in multi-select mode.
private val multipleMediaPicker =
AndroidPhotoPicker.multipleMediaPicker(
{ getFragment() },
{ getPromptsFeature() },
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
savedLoginsLauncher = registerForActivityResult { navigateToSavedLoginsFragment() }
}
private fun getFragment(): Fragment {
return this
}
private fun getPromptsFeature(): PromptFeature? {
return promptsFeature.get()
}
@CallSuper
override fun onCreateView(
inflater: LayoutInflater,
@ -901,6 +924,11 @@ abstract class BaseBrowserFragment :
findNavController().navigate(directions)
}
},
androidPhotoPicker = AndroidPhotoPicker(
requireContext(),
singleMediaPicker,
multipleMediaPicker,
),
),
owner = this,
view = view,

View File

@ -23,6 +23,7 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceManager
@ -51,6 +52,7 @@ import mozilla.components.feature.downloads.manager.FetchDownloadManager
import mozilla.components.feature.downloads.temporary.ShareDownloadFeature
import mozilla.components.feature.media.fullscreen.MediaSessionFullscreenFeature
import mozilla.components.feature.prompts.PromptFeature
import mozilla.components.feature.prompts.file.AndroidPhotoPicker
import mozilla.components.feature.session.PictureInPictureFeature
import mozilla.components.feature.session.SessionFeature
import mozilla.components.feature.sitepermissions.SitePermissionsFeature
@ -167,6 +169,28 @@ class BrowserFragment :
// Workaround for tab not existing temporarily.
?: createTab("about:blank")
// Registers a photo picker activity launcher in single-select mode.
private val singleMediaPicker =
AndroidPhotoPicker.singleMediaPicker(
{ getFragment() },
{ getPromptFeature() },
)
// Registers a photo picker activity launcher in multi-select mode.
private val multipleMediaPicker =
AndroidPhotoPicker.multipleMediaPicker(
{ getFragment() },
{ getPromptFeature() },
)
private fun getFragment(): Fragment {
return this
}
private fun getPromptFeature(): PromptFeature? {
return promptFeature.get()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestPermissionLauncher =
@ -339,6 +363,11 @@ class BrowserFragment :
)
}
},
androidPhotoPicker = AndroidPhotoPicker(
requireContext(),
singleMediaPicker,
multipleMediaPicker,
),
),
this,
view,