Skip to content

Commit 49a0387

Browse files
joshliebedaxmobile
andauthored
Add camera / image upload capability to Duck.ai WebView (#6156)
Task/Issue URL: https://app.asana.com/1/137249556945/project/488551667048375/task/1210402314987796?focus=true ### Description Add camera / image upload capability to Duck.ai WebView. ### Steps to test this PR _Follow the testing steps in the task_ --------- Co-authored-by: Dax The Translator <daxmobile@duckduckgo.com>
1 parent f297d5b commit 49a0387

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1589
-0
lines changed

duckchat/duckchat-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dependencies {
4646
implementation AndroidX.core.ktx
4747
implementation Google.android.material
4848
implementation Google.dagger
49+
implementation AndroidX.work.runtimeKtx
4950

5051
implementation "com.squareup.logcat:logcat:_"
5152

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import com.duckduckgo.common.utils.DispatcherProvider
3333
import com.duckduckgo.di.scopes.AppScope
3434
import com.duckduckgo.duckchat.api.DuckChat
3535
import com.duckduckgo.duckchat.api.DuckChatSettingsNoParams
36+
import com.duckduckgo.duckchat.impl.feature.AIChatImageUploadFeature
3637
import com.duckduckgo.duckchat.impl.feature.DuckChatFeature
3738
import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName
3839
import com.duckduckgo.duckchat.impl.repository.DuckChatFeatureRepository
@@ -120,6 +121,11 @@ interface DuckChatInternal : DuckChat {
120121
* Returns the current chat state.
121122
*/
122123
val chatState: StateFlow<ChatState>
124+
125+
/**
126+
* Returns whether image upload is enabled or not.
127+
*/
128+
fun isImageUploadEnabled(): Boolean
123129
}
124130

125131
enum class ChatState(val value: String) {
@@ -162,6 +168,7 @@ class RealDuckChat @Inject constructor(
162168
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
163169
private val pixel: Pixel,
164170
private val experimentDataStore: VisualDesignExperimentDataStore,
171+
private val imageUploadFeature: AIChatImageUploadFeature,
165172
) : DuckChatInternal, PrivacyConfigCallbackPlugin {
166173

167174
private val closeChatFlow = MutableSharedFlow<Unit>(replay = 0)
@@ -178,6 +185,7 @@ class RealDuckChat @Inject constructor(
178185
private var duckChatLink = DUCK_CHAT_WEB_LINK
179186
private var bangRegex: Regex? = null
180187
private var isAddressBarEntryPointEnabled: Boolean = false
188+
private var isImageUploadEnabled: Boolean = false
181189

182190
init {
183191
if (isMainProcess) {
@@ -276,6 +284,8 @@ class RealDuckChat @Inject constructor(
276284

277285
override val chatState: StateFlow<ChatState> get() = _chatState.asStateFlow()
278286

287+
override fun isImageUploadEnabled(): Boolean = isImageUploadEnabled
288+
279289
override fun openDuckChat(query: String?) {
280290
val parameters = query?.let { originalQuery ->
281291
val hasDuckChatBang = isDuckChatBang(originalQuery.toUri())
@@ -383,6 +393,7 @@ class RealDuckChat @Inject constructor(
383393
bangRegex = settingsJson.aiChatBangRegex?.replace("{bangs}", bangAlternation)?.toRegex()
384394
}
385395
isAddressBarEntryPointEnabled = settingsJson?.addressBarEntryPoint ?: false
396+
isImageUploadEnabled = imageUploadFeature.self().isEnabled()
386397
cacheUserSettings()
387398
}
388399
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.duckchat.impl.feature
18+
19+
import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.duckduckgo.feature.toggles.api.Toggle
22+
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
23+
24+
@ContributesRemoteFeature(
25+
scope = AppScope::class,
26+
featureName = "aiChatImageUpload",
27+
)
28+
29+
interface AIChatImageUploadFeature {
30+
31+
@Toggle.DefaultValue(DefaultFeatureValue.TRUE)
32+
fun self(): Toggle
33+
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class RealDuckChatJSHelper @Inject constructor(
104104
put(SUPPORTS_CLOSING_AI_CHAT, true)
105105
put(SUPPORTS_OPENING_SETTINGS, true)
106106
put(SUPPORTS_NATIVE_CHAT_INPUT, experimentDataStore.isExperimentEnabled.value && experimentDataStore.isDuckAIPoCEnabled.value)
107+
put(SUPPORTS_IMAGE_UPLOAD, duckChat.isImageUploadEnabled())
107108
}
108109
return JsCallbackData(jsonPayload, featureName, method, id)
109110
}
@@ -129,6 +130,7 @@ class RealDuckChatJSHelper @Inject constructor(
129130
private const val SUPPORTS_CLOSING_AI_CHAT = "supportsClosingAIChat"
130131
private const val SUPPORTS_OPENING_SETTINGS = "supportsOpeningSettings"
131132
private const val SUPPORTS_NATIVE_CHAT_INPUT = "supportsNativeChatInput"
133+
private const val SUPPORTS_IMAGE_UPLOAD = "supportsImageUpload"
132134
private const val PLATFORM = "platform"
133135
private const val ANDROID = "android"
134136
}

duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/ui/DuckChatWebViewActivity.kt

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@ package com.duckduckgo.duckchat.impl.ui
1818

1919
import android.Manifest
2020
import android.annotation.SuppressLint
21+
import android.content.Intent
2122
import android.content.pm.PackageManager
23+
import android.net.Uri
2224
import android.os.Bundle
2325
import android.os.Environment
2426
import android.os.Message
27+
import android.provider.MediaStore
2528
import android.view.MenuItem
2629
import android.view.ViewGroup
30+
import android.webkit.MimeTypeMap
31+
import android.webkit.ValueCallback
2732
import android.webkit.WebChromeClient
33+
import android.webkit.WebChromeClient.FileChooserParams
2834
import android.webkit.WebSettings
2935
import android.webkit.WebView
3036
import androidx.annotation.AnyThread
@@ -37,6 +43,7 @@ import com.duckduckgo.app.di.AppCoroutineScope
3743
import com.duckduckgo.app.tabs.BrowserNav
3844
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
3945
import com.duckduckgo.common.ui.DuckDuckGoActivity
46+
import com.duckduckgo.common.ui.view.dialog.ActionBottomSheetDialog
4047
import com.duckduckgo.common.ui.view.makeSnackbarWithNoBottomInset
4148
import com.duckduckgo.common.utils.ConflatedJob
4249
import com.duckduckgo.common.utils.DispatcherProvider
@@ -54,10 +61,18 @@ import com.duckduckgo.duckchat.impl.R
5461
import com.duckduckgo.duckchat.impl.feature.AIChatDownloadFeature
5562
import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper
5663
import com.duckduckgo.duckchat.impl.helper.RealDuckChatJSHelper.Companion.DUCK_CHAT_FEATURE_NAME
64+
import com.duckduckgo.duckchat.impl.ui.filechooser.FileChooserIntentBuilder
65+
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.camera.CameraHardwareChecker
66+
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher
67+
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.CouldNotCapturePermissionDenied
68+
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.ErrorAccessingMediaApp
69+
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.MediaCaptured
70+
import com.duckduckgo.duckchat.impl.ui.filechooser.capture.launcher.UploadFromExternalMediaAppLauncher.MediaCaptureResult.NoMediaCaptured
5771
import com.duckduckgo.js.messaging.api.JsMessageCallback
5872
import com.duckduckgo.js.messaging.api.JsMessaging
5973
import com.duckduckgo.navigation.api.GlobalActivityStarter
6074
import com.duckduckgo.navigation.api.getActivityParams
75+
import com.google.android.material.snackbar.BaseTransientBottomBar
6176
import com.google.android.material.snackbar.Snackbar
6277
import java.io.File
6378
import javax.inject.Inject
@@ -114,9 +129,20 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
114129
@Inject
115130
lateinit var aiChatDownloadFeature: AIChatDownloadFeature
116131

132+
@Inject
133+
lateinit var fileChooserIntentBuilder: FileChooserIntentBuilder
134+
135+
@Inject
136+
lateinit var cameraHardwareChecker: CameraHardwareChecker
137+
138+
@Inject
139+
lateinit var externalCameraLauncher: UploadFromExternalMediaAppLauncher
140+
117141
private var pendingFileDownload: PendingFileDownload? = null
118142
private val downloadMessagesJob = ConflatedJob()
119143

144+
private var pendingUploadTask: ValueCallback<Array<Uri>>? = null
145+
120146
private val root: ViewGroup by lazy { findViewById(android.R.id.content) }
121147
private val toolbar: Toolbar? by lazy { findViewById(com.duckduckgo.mobile.android.R.id.toolbar) }
122148
internal val simpleWebview: WebView by lazy { findViewById(R.id.simpleWebview) }
@@ -156,6 +182,21 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
156182
}
157183
return false
158184
}
185+
186+
override fun onShowFileChooser(
187+
webView: WebView,
188+
filePathCallback: ValueCallback<Array<Uri>>,
189+
fileChooserParams: FileChooserParams,
190+
): Boolean {
191+
return try {
192+
showFileChooser(filePathCallback, fileChooserParams)
193+
true
194+
} catch (e: Throwable) {
195+
// cancel the request using the documented way
196+
filePathCallback.onReceiveValue(null)
197+
throw e
198+
}
199+
}
159200
}
160201

161202
it.settings.apply {
@@ -209,6 +250,146 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
209250
url?.let {
210251
simpleWebview.loadUrl(it)
211252
}
253+
254+
externalCameraLauncher.registerForResult(this) {
255+
when (it) {
256+
is MediaCaptured -> pendingUploadTask?.onReceiveValue(arrayOf(Uri.fromFile(it.file)))
257+
is CouldNotCapturePermissionDenied -> {
258+
pendingUploadTask?.onReceiveValue(null)
259+
externalCameraLauncher.showPermissionRationaleDialog(this, it.inputAction)
260+
}
261+
262+
is NoMediaCaptured -> pendingUploadTask?.onReceiveValue(null)
263+
is ErrorAccessingMediaApp -> {
264+
pendingUploadTask?.onReceiveValue(null)
265+
Snackbar.make(root, it.messageId, BaseTransientBottomBar.LENGTH_SHORT).show()
266+
}
267+
}
268+
pendingUploadTask = null
269+
}
270+
}
271+
272+
data class FileChooserRequestedParams(
273+
val filePickingMode: Int,
274+
val acceptMimeTypes: List<String>,
275+
)
276+
277+
fun showFileChooser(
278+
filePathCallback: ValueCallback<Array<Uri>>,
279+
fileChooserParams: FileChooserParams,
280+
) {
281+
val mimeTypes = convertAcceptTypesToMimeTypes(fileChooserParams.acceptTypes)
282+
val fileChooserRequestedParams = FileChooserRequestedParams(fileChooserParams.mode, mimeTypes)
283+
val cameraHardwareAvailable = cameraHardwareChecker.hasCameraHardware()
284+
285+
when {
286+
fileChooserParams.isCaptureEnabled -> {
287+
when {
288+
acceptsOnly("image/", fileChooserParams.acceptTypes) && cameraHardwareAvailable ->
289+
launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE)
290+
291+
acceptsOnly("video/", fileChooserParams.acceptTypes) && cameraHardwareAvailable ->
292+
launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE)
293+
294+
acceptsOnly("audio/", fileChooserParams.acceptTypes) ->
295+
launchCameraCapture(filePathCallback, fileChooserRequestedParams, MediaStore.Audio.Media.RECORD_SOUND_ACTION)
296+
297+
else ->
298+
launchFilePicker(filePathCallback, fileChooserRequestedParams)
299+
}
300+
}
301+
302+
fileChooserParams.acceptTypes.any { it.startsWith("image/") && cameraHardwareAvailable } ->
303+
launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_IMAGE_CAPTURE)
304+
305+
fileChooserParams.acceptTypes.any { it.startsWith("video/") && cameraHardwareAvailable } ->
306+
launchImageOrCameraChooser(filePathCallback, fileChooserRequestedParams, MediaStore.ACTION_VIDEO_CAPTURE)
307+
308+
else ->
309+
launchFilePicker(filePathCallback, fileChooserRequestedParams)
310+
}
311+
}
312+
313+
private fun launchFilePicker(
314+
filePathCallback: ValueCallback<Array<Uri>>,
315+
fileChooserParams: FileChooserRequestedParams,
316+
) {
317+
pendingUploadTask = filePathCallback
318+
val canChooseMultipleFiles = fileChooserParams.filePickingMode == FileChooserParams.MODE_OPEN_MULTIPLE
319+
val intent = fileChooserIntentBuilder.intent(fileChooserParams.acceptMimeTypes.toTypedArray(), canChooseMultipleFiles)
320+
startActivityForResult(intent, REQUEST_CODE_CHOOSE_FILE)
321+
}
322+
323+
private fun launchCameraCapture(
324+
filePathCallback: ValueCallback<Array<Uri>>,
325+
fileChooserParams: FileChooserRequestedParams,
326+
inputAction: String,
327+
) {
328+
if (Intent(inputAction).resolveActivity(packageManager) == null) {
329+
launchFilePicker(filePathCallback, fileChooserParams)
330+
return
331+
}
332+
333+
pendingUploadTask = filePathCallback
334+
externalCameraLauncher.launch(inputAction)
335+
}
336+
337+
private fun launchImageOrCameraChooser(
338+
filePathCallback: ValueCallback<Array<Uri>>,
339+
fileChooserParams: FileChooserRequestedParams,
340+
inputAction: String,
341+
) {
342+
val cameraString = getString(R.string.imageCaptureCameraGalleryDisambiguationCameraOption)
343+
val cameraIcon = com.duckduckgo.mobile.android.R.drawable.ic_camera_24
344+
345+
val galleryString = getString(R.string.imageCaptureCameraGalleryDisambiguationGalleryOption)
346+
val galleryIcon = com.duckduckgo.mobile.android.R.drawable.ic_image_24
347+
348+
ActionBottomSheetDialog.Builder(this)
349+
.setTitle(getString(R.string.imageCaptureCameraGalleryDisambiguationTitle))
350+
.setPrimaryItem(galleryString, galleryIcon)
351+
.setSecondaryItem(cameraString, cameraIcon)
352+
.addEventListener(
353+
object : ActionBottomSheetDialog.EventListener() {
354+
override fun onPrimaryItemClicked() {
355+
launchFilePicker(filePathCallback, fileChooserParams)
356+
}
357+
358+
override fun onSecondaryItemClicked() {
359+
launchCameraCapture(filePathCallback, fileChooserParams, inputAction)
360+
}
361+
362+
override fun onBottomSheetDismissed() {
363+
filePathCallback.onReceiveValue(null)
364+
pendingUploadTask = null
365+
}
366+
},
367+
)
368+
.show()
369+
}
370+
371+
private fun acceptsOnly(
372+
type: String,
373+
acceptTypes: Array<String>,
374+
): Boolean {
375+
return acceptTypes.filter { it.startsWith(type) }.size == acceptTypes.size
376+
}
377+
378+
private fun convertAcceptTypesToMimeTypes(acceptTypes: Array<String>): List<String> {
379+
val mimeTypeMap = MimeTypeMap.getSingleton()
380+
val mimeTypes = mutableSetOf<String>()
381+
acceptTypes.forEach { type ->
382+
// Attempt to convert any identified file extensions into corresponding MIME types.
383+
val fileExtension = MimeTypeMap.getFileExtensionFromUrl(type)
384+
if (fileExtension.isNotEmpty()) {
385+
mimeTypeMap.getMimeTypeFromExtension(type.substring(1))?.let {
386+
mimeTypes.add(it)
387+
}
388+
} else {
389+
mimeTypes.add(type)
390+
}
391+
}
392+
return mimeTypes.toList()
212393
}
213394

214395
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@@ -322,6 +503,30 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
322503
requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE)
323504
}
324505

506+
override fun onActivityResult(
507+
requestCode: Int,
508+
resultCode: Int,
509+
data: Intent?,
510+
) {
511+
super.onActivityResult(requestCode, resultCode, data)
512+
if (requestCode == REQUEST_CODE_CHOOSE_FILE) {
513+
handleFileUploadResult(resultCode, data)
514+
}
515+
}
516+
517+
private fun handleFileUploadResult(
518+
resultCode: Int,
519+
intent: Intent?,
520+
) {
521+
if (resultCode != RESULT_OK || intent == null) {
522+
pendingUploadTask?.onReceiveValue(null)
523+
return
524+
}
525+
526+
val uris = fileChooserIntentBuilder.extractSelectedFileUris(intent)
527+
pendingUploadTask?.onReceiveValue(uris)
528+
}
529+
325530
override fun onResume() {
326531
launchDownloadMessagesJob()
327532
super.onResume()
@@ -336,5 +541,6 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
336541
private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200
337542
private const val CUSTOM_UA =
338543
"Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/124.0.0.0 Mobile DuckDuckGo/5 Safari/537.36"
544+
private const val REQUEST_CODE_CHOOSE_FILE = 100
339545
}
340546
}

0 commit comments

Comments
 (0)