@@ -18,13 +18,19 @@ package com.duckduckgo.duckchat.impl.ui
18
18
19
19
import android.Manifest
20
20
import android.annotation.SuppressLint
21
+ import android.content.Intent
21
22
import android.content.pm.PackageManager
23
+ import android.net.Uri
22
24
import android.os.Bundle
23
25
import android.os.Environment
24
26
import android.os.Message
27
+ import android.provider.MediaStore
25
28
import android.view.MenuItem
26
29
import android.view.ViewGroup
30
+ import android.webkit.MimeTypeMap
31
+ import android.webkit.ValueCallback
27
32
import android.webkit.WebChromeClient
33
+ import android.webkit.WebChromeClient.FileChooserParams
28
34
import android.webkit.WebSettings
29
35
import android.webkit.WebView
30
36
import androidx.annotation.AnyThread
@@ -37,6 +43,7 @@ import com.duckduckgo.app.di.AppCoroutineScope
37
43
import com.duckduckgo.app.tabs.BrowserNav
38
44
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
39
45
import com.duckduckgo.common.ui.DuckDuckGoActivity
46
+ import com.duckduckgo.common.ui.view.dialog.ActionBottomSheetDialog
40
47
import com.duckduckgo.common.ui.view.makeSnackbarWithNoBottomInset
41
48
import com.duckduckgo.common.utils.ConflatedJob
42
49
import com.duckduckgo.common.utils.DispatcherProvider
@@ -54,10 +61,18 @@ import com.duckduckgo.duckchat.impl.R
54
61
import com.duckduckgo.duckchat.impl.feature.AIChatDownloadFeature
55
62
import com.duckduckgo.duckchat.impl.helper.DuckChatJSHelper
56
63
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
57
71
import com.duckduckgo.js.messaging.api.JsMessageCallback
58
72
import com.duckduckgo.js.messaging.api.JsMessaging
59
73
import com.duckduckgo.navigation.api.GlobalActivityStarter
60
74
import com.duckduckgo.navigation.api.getActivityParams
75
+ import com.google.android.material.snackbar.BaseTransientBottomBar
61
76
import com.google.android.material.snackbar.Snackbar
62
77
import java.io.File
63
78
import javax.inject.Inject
@@ -114,9 +129,20 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
114
129
@Inject
115
130
lateinit var aiChatDownloadFeature: AIChatDownloadFeature
116
131
132
+ @Inject
133
+ lateinit var fileChooserIntentBuilder: FileChooserIntentBuilder
134
+
135
+ @Inject
136
+ lateinit var cameraHardwareChecker: CameraHardwareChecker
137
+
138
+ @Inject
139
+ lateinit var externalCameraLauncher: UploadFromExternalMediaAppLauncher
140
+
117
141
private var pendingFileDownload: PendingFileDownload ? = null
118
142
private val downloadMessagesJob = ConflatedJob ()
119
143
144
+ private var pendingUploadTask: ValueCallback <Array <Uri >>? = null
145
+
120
146
private val root: ViewGroup by lazy { findViewById(android.R .id.content) }
121
147
private val toolbar: Toolbar ? by lazy { findViewById(com.duckduckgo.mobile.android.R .id.toolbar) }
122
148
internal val simpleWebview: WebView by lazy { findViewById(R .id.simpleWebview) }
@@ -156,6 +182,21 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
156
182
}
157
183
return false
158
184
}
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
+ }
159
200
}
160
201
161
202
it.settings.apply {
@@ -209,6 +250,146 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
209
250
url?.let {
210
251
simpleWebview.loadUrl(it)
211
252
}
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()
212
393
}
213
394
214
395
override fun onOptionsItemSelected (item : MenuItem ): Boolean {
@@ -322,6 +503,30 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
322
503
requestPermissions(arrayOf(Manifest .permission.WRITE_EXTERNAL_STORAGE ), PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE )
323
504
}
324
505
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
+
325
530
override fun onResume () {
326
531
launchDownloadMessagesJob()
327
532
super .onResume()
@@ -336,5 +541,6 @@ open class DuckChatWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD
336
541
private const val PERMISSION_REQUEST_WRITE_EXTERNAL_STORAGE = 200
337
542
private const val CUSTOM_UA =
338
543
" 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
339
545
}
340
546
}
0 commit comments