Skip to content

Commit

Permalink
Support taking a new photo when prompted to upload an image (#3954)
Browse files Browse the repository at this point in the history
<!--
Note: This checklist is a reminder of our shared engineering
expectations.
The items in Bold are required
If your PR involves UI changes:
1. Upload screenshots or screencasts that illustrate the changes before
/ after
2. Add them under the UI changes section (feel free to add more columns
if needed)
If your PR does not involve UI changes, you can remove the **UI
changes** section

At a minimum, make sure your changes are tested in API 23 and one of the
more recent API levels available.
-->

Task/Issue URL:
https://app.asana.com/0/488551667048375/1206005059663926/f

### Description
When encountering webpages which allow users to upload an image,
currently the only option is to choose an existing image from the
gallery.

With this PR, users will have the ability to choose between uploading an
existing image from their gallery or taking a new photo from the camera
and uploading that.

Translations will follow separately.

### Steps to test this PR


#### Happy path, camera
- [x] Visit https://cdrussell.w3spaces.com and click the **Choose File**
button
- [x] Choose **Take Photo**
- [x] Grant camera permission when prompted
- [x] Take a photo
- [x] Verify it appears on the demo webpage

#### Happy path, gallery
- [x] Visit https://cdrussell.w3spaces.com and click the **Choose File**
button
- [x] Choose **Photo Library**
- [x] Verify you see the standard file chooser as you would just now in
`develop` and that you can select an image (nothing has changed about
this flow)

#### Alt path, cancel image capture
- [x] Visit https://cdrussell.w3spaces.com and click the **Choose File**
button
- [x] Choose **Take Photo**
- [x] (assuming you still have camera permission from previous test;
accept permission if needed)
- [x] When the camera app loads, cancel without taking an image
- [x] Choose **Take Photo** again to sense-check the flow can be
repeated after cancellation

#### Alt path, refusing camera permission
- [x] Clear camera permissions if granted, manually or using `adb shell
pm reset-permissions`
- [x] Visit https://cdrussell.w3spaces.com and click the **Choose File**
button
- [x] Choose **Take Photo**
- [x] **Refuse** camera permission
- [x] Verify nothing else shown to user (just quietly returned to
website with no dialogs showing)
- [x] Choose **Take Photo** again, and again refuse permission when
prompted
- [x] Verify this time the permission rationale prompt shows explaining
why we'd need this permission to do what the user is asking
- [x] Click on the **Open Settings** button and verify it takes you to
the right place to alter system permissions for the app

#### Alt path, uploading a non-image file
- [x] Visit https://cdrussell.w3spaces.com/other.html
- [x] Tap the third button (`video/*` upload type)
- [x] Verify you are not given the option to use the camera (only
supporting images at the moment)

### UI changes

<img width=50%
src="https://github.com/duckduckgo/Android/assets/1336281/9b6c64d6-20cd-43aa-b0b0-2f039d96dcc7"
/>

<hr/>
<hr/>

<img width=50%
src="https://github.com/duckduckgo/Android/assets/1336281/0905276b-80a9-4790-8a12-7859c3edafac"
/>
  • Loading branch information
CDRussell authored Dec 8, 2023
1 parent 46c15bb commit 8a937ae
Show file tree
Hide file tree
Showing 12 changed files with 758 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import android.view.View
import android.webkit.GeolocationPermissions
import android.webkit.HttpAuthHandler
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient.FileChooserParams
import android.webkit.WebView
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.core.net.toUri
Expand Down Expand Up @@ -54,6 +56,7 @@ import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.DownloadFile
import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.OpenInNewTab
import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector
import com.duckduckgo.app.browser.applinks.AppLinksHandler
import com.duckduckgo.app.browser.camera.CameraHardwareChecker
import com.duckduckgo.app.browser.favicon.FaviconManager
import com.duckduckgo.app.browser.favicon.FaviconSource
import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter
Expand Down Expand Up @@ -328,6 +331,9 @@ class BrowserTabViewModelTest {
@Mock
private lateinit var mockSurveyRepository: SurveyRepository

@Mock
private lateinit var mockFileChooserCallback: ValueCallback<Array<Uri>>

private lateinit var remoteMessagingModel: RemoteMessagingModel

private val lazyFaviconManager = Lazy { mockFaviconManager }
Expand Down Expand Up @@ -390,6 +396,8 @@ class BrowserTabViewModelTest {

private val mockSyncEngine: SyncEngine = mock()

private val cameraHardwareChecker: CameraHardwareChecker = mock()

@Before
fun before() {
MockitoAnnotations.openMocks(this)
Expand Down Expand Up @@ -457,6 +465,7 @@ class BrowserTabViewModelTest {
whenever(mockUserAllowListRepository.domainsInUserAllowListFlow()).thenReturn(flowOf(emptyList()))
whenever(mockContentBlocking.isAnException(anyString())).thenReturn(false)
whenever(fireproofDialogsEventHandler.event).thenReturn(fireproofDialogsEventHandlerLiveData)
whenever(cameraHardwareChecker.hasCameraHardware()).thenReturn(true)

testee = BrowserTabViewModel(
statisticsUpdater = mockStatisticsUpdater,
Expand Down Expand Up @@ -510,6 +519,7 @@ class BrowserTabViewModelTest {
syncEngine = mockSyncEngine,
device = mockDeviceInfo,
sitePermissionsManager = mockSitePermissionsManager,
cameraHardwareChecker = cameraHardwareChecker,
)

testee.loadData("abc", null, false, false)
Expand Down Expand Up @@ -4508,6 +4518,42 @@ class BrowserTabViewModelTest {
verify(mockSyncEngine).triggerSync(FEATURE_READ)
}

@Test
fun whenOnShowFileChooserWithImageWildcardedTypeThenImageOrCameraChooserCommandSent() {
val params = buildFileChooserParams(arrayOf("image/*"))
testee.showFileChooser(mockFileChooserCallback, params)
assertCommandIssued<Command.ShowExistingImageOrCameraChooser>()
}

@Test
fun whenOnShowFileChooserWithImageWildcardedTypeButCameraHardwareUnavailableThenFileChooserCommandSent() {
whenever(cameraHardwareChecker.hasCameraHardware()).thenReturn(false)
val params = buildFileChooserParams(arrayOf("image/*"))
testee.showFileChooser(mockFileChooserCallback, params)
assertCommandIssued<Command.ShowFileChooser>()
}

@Test
fun whenOnShowFileChooserContainsImageWildcardedTypeThenImageOrCameraChooserCommandSent() {
val params = buildFileChooserParams(arrayOf("image/*", "application/pdf"))
testee.showFileChooser(mockFileChooserCallback, params)
assertCommandIssued<Command.ShowExistingImageOrCameraChooser>()
}

@Test
fun whenOnShowFileChooserWithImageSpecificTypeThenExistingFileChooserCommandSent() {
val params = buildFileChooserParams(arrayOf("image/png"))
testee.showFileChooser(mockFileChooserCallback, params)
assertCommandIssued<Command.ShowFileChooser>()
}

@Test
fun whenOnShowFileChooserWithNonImageTypeThenExistingFileChooserCommandSent() {
val params = buildFileChooserParams(arrayOf("application/pdf"))
testee.showFileChooser(mockFileChooserCallback, params)
assertCommandIssued<Command.ShowFileChooser>()
}

private fun aCredential(): LoginCredentials {
return LoginCredentials(domain = null, username = null, password = null)
}
Expand Down Expand Up @@ -4771,6 +4817,17 @@ class BrowserTabViewModelTest {
return nav
}

private fun buildFileChooserParams(acceptTypes: Array<String>): FileChooserParams {
return object : FileChooserParams() {
override fun getAcceptTypes(): Array<String> = acceptTypes
override fun getMode(): Int = 0
override fun isCaptureEnabled(): Boolean = false
override fun getTitle(): CharSequence? = null
override fun getFilenameHint(): String? = null
override fun createIntent(): Intent = Intent()
}
}

private fun privacyShieldState() = testee.privacyShieldViewState.value!!
private fun ctaViewState() = testee.ctaViewState.value!!
private fun browserViewState() = testee.browserViewState.value!!
Expand Down
81 changes: 73 additions & 8 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import android.webkit.PermissionRequest
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebChromeClient.FileChooserParams
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebView.FindListener
Expand Down Expand Up @@ -109,6 +110,11 @@ import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.Companio
import com.duckduckgo.app.browser.favorites.FavoritesQuickAccessAdapter.QuickAccessFavorite
import com.duckduckgo.app.browser.favorites.QuickAccessDragTouchItemListener
import com.duckduckgo.app.browser.filechooser.FileChooserIntentBuilder
import com.duckduckgo.app.browser.filechooser.camera.launcher.UploadFromExternalCameraLauncher
import com.duckduckgo.app.browser.filechooser.camera.launcher.UploadFromExternalCameraLauncher.CameraImageCaptureResult.CouldNotCapturePermissionDenied
import com.duckduckgo.app.browser.filechooser.camera.launcher.UploadFromExternalCameraLauncher.CameraImageCaptureResult.ErrorAccessingCamera
import com.duckduckgo.app.browser.filechooser.camera.launcher.UploadFromExternalCameraLauncher.CameraImageCaptureResult.ImageCaptured
import com.duckduckgo.app.browser.filechooser.camera.launcher.UploadFromExternalCameraLauncher.CameraImageCaptureResult.NoImageCaptured
import com.duckduckgo.app.browser.history.NavigationHistorySheet
import com.duckduckgo.app.browser.history.NavigationHistorySheet.NavigationHistorySheetListener
import com.duckduckgo.app.browser.httpauth.WebViewHttpAuthStore
Expand Down Expand Up @@ -195,11 +201,11 @@ import com.duckduckgo.autofill.api.store.AutofillStore.ContainsCredentialsResult
import com.duckduckgo.browser.api.brokensite.BrokenSiteData
import com.duckduckgo.common.ui.DuckDuckGoFragment
import com.duckduckgo.common.ui.store.BrowserAppTheme
import com.duckduckgo.common.ui.view.*
import com.duckduckgo.common.ui.view.DaxDialog
import com.duckduckgo.common.ui.view.DaxDialogListener
import com.duckduckgo.common.ui.view.KeyboardAwareEditText
import com.duckduckgo.common.ui.view.KeyboardAwareEditText.ShowSuggestionsListener
import com.duckduckgo.common.ui.view.dialog.ActionBottomSheetDialog
import com.duckduckgo.common.ui.view.dialog.CustomAlertDialogBuilder
import com.duckduckgo.common.ui.view.dialog.DaxAlertDialog
import com.duckduckgo.common.ui.view.dialog.StackedAlertDialogBuilder
Expand Down Expand Up @@ -416,6 +422,9 @@ class BrowserTabFragment :
@Inject
lateinit var jsMessageHelper: JsMessageHelper

@Inject
lateinit var externalCameraLauncher: UploadFromExternalCameraLauncher

/**
* We use this to monitor whether the user was seeing the in-context Email Protection signup prompt
* This is needed because the activity stack will be cleared if an external link is opened in our browser
Expand Down Expand Up @@ -715,6 +724,23 @@ class BrowserTabFragment :
}
}
sitePermissionsDialogLauncher.registerPermissionLauncher(this)
externalCameraLauncher.registerForResult(this) {
when (it) {
is ImageCaptured -> pendingUploadTask?.onReceiveValue(arrayOf(Uri.fromFile(it.file)))
CouldNotCapturePermissionDenied -> {
pendingUploadTask?.onReceiveValue(null)
activity?.let { activity ->
externalCameraLauncher.showPermissionRationaleDialog(activity)
}
}
NoImageCaptured -> pendingUploadTask?.onReceiveValue(null)
ErrorAccessingCamera -> {
pendingUploadTask?.onReceiveValue(null)
Snackbar.make(binding.root, R.string.imageCaptureCameraUnavailable, BaseTransientBottomBar.LENGTH_SHORT).show()
}
}
pendingUploadTask = null
}
}

private fun resumeWebView() {
Expand Down Expand Up @@ -1185,9 +1211,8 @@ class BrowserTabFragment :
is Command.ShareLink -> launchSharePageChooser(it.url, it.title)
is Command.SharePromoLinkRMF -> launchSharePromoRMFPageChooser(it.url, it.shareTitle)
is Command.CopyLink -> clipboardManager.setPrimaryClip(ClipData.newPlainText(null, it.url))
is Command.ShowFileChooser -> {
launchFilePicker(it)
}
is Command.ShowFileChooser -> launchFilePicker(it.filePathCallback, it.fileChooserParams)
is Command.ShowExistingImageOrCameraChooser -> launchImageOrCameraChooser(it.fileChooserParams, it.filePathCallback)

is Command.AddHomeShortcut -> {
context?.let { context ->
Expand Down Expand Up @@ -2713,13 +2738,53 @@ class BrowserTabFragment :
showDialogHidingPrevious(downloadConfirmationFragment, DOWNLOAD_CONFIRMATION_TAG)
}

private fun launchFilePicker(command: Command.ShowFileChooser) {
pendingUploadTask = command.filePathCallback
val canChooseMultipleFiles = command.fileChooserParams.mode == WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE
val intent = fileChooserIntentBuilder.intent(command.fileChooserParams.acceptTypes, canChooseMultipleFiles)
private fun launchFilePicker(filePathCallback: ValueCallback<Array<Uri>>, fileChooserParams: WebChromeClient.FileChooserParams) {
pendingUploadTask = filePathCallback
val canChooseMultipleFiles = fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE
val intent = fileChooserIntentBuilder.intent(fileChooserParams.acceptTypes, canChooseMultipleFiles)
startActivityForResult(intent, REQUEST_CODE_CHOOSE_FILE)
}

private fun launchCameraCapture(filePathCallback: ValueCallback<Array<Uri>>) {
pendingUploadTask = filePathCallback
externalCameraLauncher.launch()
}

private fun launchImageOrCameraChooser(
fileChooserParams: FileChooserParams,
filePathCallback: ValueCallback<Array<Uri>>,
) {
context?.let {
val cameraString = getString(R.string.imageCaptureCameraGalleryDisambiguationCameraOption)
val cameraIcon = com.duckduckgo.mobile.android.R.drawable.ic_camera_24

val galleryString = getString(R.string.imageCaptureCameraGalleryDisambiguationGalleryOption)
val galleryIcon = com.duckduckgo.mobile.android.R.drawable.ic_image_24

ActionBottomSheetDialog.Builder(it)
.setTitle(getString(R.string.imageCaptureCameraGalleryDisambiguationTitle))
.setPrimaryItem(galleryString, galleryIcon)
.setSecondaryItem(cameraString, cameraIcon)
.addEventListener(
object : ActionBottomSheetDialog.EventListener() {
override fun onPrimaryItemClicked() {
launchFilePicker(filePathCallback, fileChooserParams)
}

override fun onSecondaryItemClicked() {
launchCameraCapture(filePathCallback)
}

override fun onBottomSheetDismissed() {
filePathCallback.onReceiveValue(null)
pendingUploadTask = null
}
},
)
.show()
}
}

private fun minSdk30(): Boolean {
return appBuildConfig.sdkInt >= Build.VERSION_CODES.R
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import android.webkit.GeolocationPermissions
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebChromeClient.FileChooserParams
import android.webkit.WebView
import androidx.annotation.AnyThread
import androidx.annotation.VisibleForTesting
Expand Down Expand Up @@ -57,6 +58,7 @@ import com.duckduckgo.app.browser.SpecialUrlDetector.UrlType.NonHttpAppLink
import com.duckduckgo.app.browser.WebViewErrorResponse.OMITTED
import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector
import com.duckduckgo.app.browser.applinks.AppLinksHandler
import com.duckduckgo.app.browser.camera.CameraHardwareChecker
import com.duckduckgo.app.browser.favicon.FaviconManager
import com.duckduckgo.app.browser.favicon.FaviconSource.ImageFavicon
import com.duckduckgo.app.browser.favicon.FaviconSource.UrlFavicon
Expand Down Expand Up @@ -210,6 +212,7 @@ class BrowserTabViewModel @Inject constructor(
private val device: DeviceInfo,
private val sitePermissionsManager: SitePermissionsManager,
private val syncEngine: SyncEngine,
private val cameraHardwareChecker: CameraHardwareChecker,
) : WebViewClientListener,
EditSavedSiteListener,
DeleteBookmarkListener,
Expand Down Expand Up @@ -396,6 +399,10 @@ class BrowserTabViewModel @Inject constructor(
val filePathCallback: ValueCallback<Array<Uri>>,
val fileChooserParams: WebChromeClient.FileChooserParams,
) : Command()
class ShowExistingImageOrCameraChooser(
val filePathCallback: ValueCallback<Array<Uri>>,
val fileChooserParams: WebChromeClient.FileChooserParams,
) : Command()

class HandleNonHttpAppLink(
val nonHttpAppLink: NonHttpAppLink,
Expand Down Expand Up @@ -1867,9 +1874,13 @@ class BrowserTabViewModel @Inject constructor(

override fun showFileChooser(
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: WebChromeClient.FileChooserParams,
fileChooserParams: FileChooserParams,
) {
command.value = ShowFileChooser(filePathCallback, fileChooserParams)
if (fileChooserParams.acceptTypes.contains("image/*") && cameraHardwareChecker.hasCameraHardware()) {
command.value = ShowExistingImageOrCameraChooser(filePathCallback, fileChooserParams)
} else {
command.value = ShowFileChooser(filePathCallback, fileChooserParams)
}
}

private fun currentGlobalLayoutState(): GlobalLayoutViewState = globalLayoutState.value!!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.app.browser.camera

import android.content.Context
import android.content.pm.PackageManager.FEATURE_CAMERA_ANY
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject

interface CameraHardwareChecker {
fun hasCameraHardware(): Boolean
}

@ContributesBinding(AppScope::class)
class CameraHardwareCheckerImpl @Inject constructor(
private val context: Context,
) : CameraHardwareChecker {

override fun hasCameraHardware(): Boolean {
return with(context.packageManager) {
kotlin.runCatching { hasSystemFeature(FEATURE_CAMERA_ANY) }.getOrDefault(false)
}
}
}
Loading

0 comments on commit 8a937ae

Please sign in to comment.