Skip to content

Commit 16ba84a

Browse files
feat(android): allow for photo and video capturing and filter for media type for file uploads (onShowFileChooser)
feat(android): allow for photo and video capturing and filter for media type for file uploads (`onShowFileChooser`)
2 parents 7433640 + 6516ba5 commit 16ba84a

6 files changed

Lines changed: 235 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [1.6.0]
8+
9+
### Features
10+
11+
- Allow for photo and video capturing, and filter for media type for file uploads (`onShowFileChooser`) [RMET-4466](https://outsystemsrd.atlassian.net/browse/RMET-4466)
12+
713
## [1.5.0]
814

915
### Features

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
<modelVersion>4.0.0</modelVersion>
77
<groupId>io.ionic.libs</groupId>
88
<artifactId>ioninappbrowser-android</artifactId>
9-
<version>1.5.0</version>
9+
<version>1.6.0</version>
1010
</project>

src/main/AndroidManifest.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@
2929
android:excludeFromRecents="true"
3030
android:windowSoftInputMode="stateAlwaysHidden|adjustPan" />
3131

32+
<!-- Used for when taking photos/videos when uploading files through WebChromeClient.onShowFileChooser -->
33+
<provider
34+
android:name="com.outsystems.plugins.inappbrowser.osinappbrowserlib.FileProvider"
35+
android:authorities="${applicationId}.fileprovider"
36+
android:exported="false"
37+
android:grantUriPermissions="true">
38+
<meta-data
39+
android:name="android.support.FILE_PROVIDER_PATHS"
40+
android:resource="@xml/file_paths" />
41+
</provider>
42+
3243
</application>
3344

3445
<queries>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package com.outsystems.plugins.inappbrowser.osinappbrowserlib
2+
3+
class FileProvider: androidx.core.content.FileProvider()

src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt

Lines changed: 210 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ package com.outsystems.plugins.inappbrowser.osinappbrowserlib.views
22

33
import android.Manifest
44
import android.app.Activity
5+
import android.content.Context
56
import android.content.Intent
67
import android.content.pm.PackageManager
7-
import android.graphics.Bitmap
88
import android.net.Uri
99
import android.os.Build
1010
import android.os.Bundle
11+
import android.provider.MediaStore
1112
import android.util.Log
1213
import android.view.Gravity
14+
import android.graphics.Bitmap
1315
import android.view.View
1416
import android.webkit.CookieManager
1517
import android.webkit.GeolocationPermissions
@@ -33,6 +35,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
3335
import androidx.constraintlayout.widget.ConstraintSet
3436
import androidx.core.app.ActivityCompat
3537
import androidx.core.content.ContextCompat
38+
import androidx.core.content.FileProvider
3639
import androidx.core.view.isVisible
3740
import androidx.lifecycle.lifecycleScope
3841
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents
@@ -44,7 +47,11 @@ import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABWebView
4447
import kotlinx.coroutines.Dispatchers
4548
import kotlinx.coroutines.launch
4649
import kotlinx.coroutines.withContext
50+
import java.io.File
4751
import java.io.IOException
52+
import java.time.LocalDateTime
53+
import java.time.format.DateTimeFormatter
54+
import java.util.Locale
4855

4956
class OSIABWebViewActivity : AppCompatActivity() {
5057

@@ -77,16 +84,37 @@ class OSIABWebViewActivity : AppCompatActivity() {
7784
private var geolocationOrigin: String? = null
7885
private var wasGeolocationPermissionDenied = false
7986

87+
// used in onShowFileChooser when taking photos or videos
88+
private var currentPhotoFile: File? = null
89+
private var currentPhotoUri: Uri? = null
90+
private var currentVideoFile: File? = null
91+
private var currentVideoUri: Uri? = null
92+
8093
// for file chooser
8194
private var filePathCallback: ValueCallback<Array<Uri>>? = null
8295
private val fileChooserLauncher =
8396
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
84-
filePathCallback?.onReceiveValue(
85-
if (result.resultCode == Activity.RESULT_OK)
86-
WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data)
87-
else null
88-
)
97+
val uris = when {
98+
result.resultCode != Activity.RESULT_OK -> null
99+
result.data?.data != null -> WebChromeClient.FileChooserParams.parseResult(
100+
result.resultCode,
101+
result.data
102+
) // file was selected from gallery or file manager, some OEMs also return the video here (e.g. Google)
103+
104+
// we need to check currentPhotoFile.length() > 0 to make sure a photo was actually taken
105+
currentPhotoUri != null && currentPhotoFile != null && currentPhotoFile!!.length() > 0 ->
106+
arrayOf(currentPhotoUri) // photo capture, since URI is not in data
107+
// we need to check currentVideoFile.length() > 0 to make sure a video was actually taken
108+
currentVideoUri != null && currentVideoFile != null && currentVideoFile!!.length() > 0 ->
109+
arrayOf(currentVideoUri) // fallback for video capture, if video URI is not in data (e.g. Samsung devices)
110+
else -> null
111+
}
112+
filePathCallback?.onReceiveValue(uris)
89113
filePathCallback = null
114+
currentPhotoFile = null
115+
currentPhotoUri = null
116+
currentVideoFile = null
117+
currentVideoUri = null
90118
}
91119

92120
// for back navigation
@@ -105,12 +133,21 @@ class OSIABWebViewActivity : AppCompatActivity() {
105133
const val ENABLED_ALPHA = 1.0f
106134
const val REQUEST_STANDARD_PERMISSION = 622
107135
const val REQUEST_LOCATION_PERMISSION = 623
136+
const val REQUEST_CAMERA_PERMISSION = 624
108137
const val LOG_TAG = "OSIABWebViewActivity"
109138
val errorsToHandle = listOf(
110139
WebViewClient.ERROR_HOST_LOOKUP,
111140
WebViewClient.ERROR_UNSUPPORTED_SCHEME,
112141
WebViewClient.ERROR_BAD_URL
113142
)
143+
144+
private fun createTempFile(context: Context, prefix: String, suffix: String): File {
145+
val storageDir = context.cacheDir
146+
val formatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.getDefault())
147+
val timeStamp = LocalDateTime.now().format(formatter)
148+
return File.createTempFile("${prefix}${timeStamp}_", suffix, storageDir)
149+
}
150+
114151
}
115152

116153
override fun onCreate(savedInstanceState: Bundle?) {
@@ -338,6 +375,18 @@ class OSIABWebViewActivity : AppCompatActivity() {
338375
geolocationCallback = null
339376
geolocationOrigin = null
340377
}
378+
REQUEST_CAMERA_PERMISSION -> {
379+
// permission granted, launch the file chooser
380+
// permission grant is determined in launchFileChooser
381+
try {
382+
filePathCallback?.let {
383+
(webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser()
384+
}
385+
} catch (e: Exception) {
386+
Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}")
387+
(webView.webChromeClient as? OSIABWebChromeClient)?.cancelFileChooser()
388+
}
389+
}
341390
}
342391
}
343392

@@ -502,6 +551,10 @@ class OSIABWebViewActivity : AppCompatActivity() {
502551
*/
503552
private inner class OSIABWebChromeClient : WebChromeClient() {
504553

554+
// for handling uploads (photo, video, gallery, files)
555+
private var acceptTypes: String = ""
556+
private var captureEnabled: Boolean = false
557+
505558
// handle standard permissions (e.g. audio, camera)
506559
override fun onPermissionRequest(request: PermissionRequest?) {
507560
request?.let {
@@ -521,31 +574,174 @@ class OSIABWebViewActivity : AppCompatActivity() {
521574

522575
// handle opening the file chooser within the WebView
523576
override fun onShowFileChooser(
524-
webView: WebView?,
525-
filePathCallback: ValueCallback<Array<Uri>>?,
526-
fileChooserParams: FileChooserParams?
577+
webView: WebView,
578+
filePathCallback: ValueCallback<Array<Uri>>,
579+
fileChooserParams: FileChooserParams
527580
): Boolean {
528581
this@OSIABWebViewActivity.filePathCallback = filePathCallback
529-
val intent = fileChooserParams?.createIntent()
582+
acceptTypes = fileChooserParams.acceptTypes.joinToString()
583+
captureEnabled = fileChooserParams.isCaptureEnabled
584+
585+
// if camera permission is declared in manifest but is not granted, request it
586+
if (hasCameraPermissionDeclared() && !isCameraPermissionGranted()) {
587+
ActivityCompat.requestPermissions(
588+
this@OSIABWebViewActivity,
589+
arrayOf(Manifest.permission.CAMERA),
590+
REQUEST_CAMERA_PERMISSION
591+
)
592+
// don’t launch chooser yet, wait for permission result
593+
return true
594+
}
595+
530596
try {
531-
fileChooserLauncher.launch(intent!!)
597+
launchFileChooser(acceptTypes, captureEnabled)
598+
return true
532599
} catch (npe: NullPointerException) {
533-
this@OSIABWebViewActivity.filePathCallback = null
534600
Log.e(
535601
LOG_TAG,
536602
"Attempted to launch but intent is null; fileChooserParams=$fileChooserParams",
537603
npe
538604
)
605+
cancelFileChooser()
539606
return false
540607
} catch (e: Exception) {
541-
this@OSIABWebViewActivity.filePathCallback = null
542608
Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}")
609+
cancelFileChooser()
543610
return false
544611
}
545-
return true
546612
}
613+
614+
fun cancelFileChooser() {
615+
filePathCallback?.onReceiveValue(null)
616+
filePathCallback = null
617+
acceptTypes = ""
618+
captureEnabled = false
619+
}
620+
621+
fun retryFileChooser() {
622+
try {
623+
launchFileChooser(acceptTypes, captureEnabled)
624+
} catch (e: Exception) {
625+
e.printStackTrace()
626+
cancelFileChooser()
627+
}
628+
acceptTypes = ""
629+
captureEnabled = false
630+
}
631+
632+
private fun launchFileChooser(acceptTypes: String = "", isCaptureEnabled: Boolean = false) {
633+
val intentList = buildPhotoVideoIntents(acceptTypes)
634+
val permissionNotDeclaredOrGranted = hasCameraPermissionDeclared().not() || isCameraPermissionGranted()
635+
636+
if (isCaptureEnabled && permissionNotDeclaredOrGranted) {
637+
// if capture is enabled, we only show the camera and video options
638+
launchCameraChooser(intentList)
639+
} else if (!isCaptureEnabled) {
640+
// if capture is not enabled, we show the full chooser
641+
launchFullChooser(intentList, acceptTypes, permissionNotDeclaredOrGranted)
642+
} else {
643+
// capture is enabled but permission declared and not granted,
644+
// as our only option is to capture, we cannot proceed
645+
cancelFileChooser()
646+
return
647+
}
648+
}
649+
650+
private fun buildPhotoVideoIntents(acceptTypes: String): MutableList<Intent> {
651+
val intentList = mutableListOf<Intent>()
652+
val permissionNotDeclaredOrGranted = hasCameraPermissionDeclared().not() || isCameraPermissionGranted()
653+
654+
if (permissionNotDeclaredOrGranted) {
655+
if (acceptTypes.contains("image") || acceptTypes.isEmpty()) {
656+
currentPhotoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg").also { file ->
657+
currentPhotoUri = FileProvider.getUriForFile(
658+
this@OSIABWebViewActivity,
659+
"${this@OSIABWebViewActivity.packageName}.fileprovider",
660+
file
661+
)
662+
}
663+
val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
664+
putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri)
665+
}
666+
intentList.add(takePictureIntent)
667+
}
668+
if (acceptTypes.contains("video") || acceptTypes.isEmpty()) {
669+
currentVideoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4").also { file ->
670+
currentVideoFile = file
671+
currentVideoUri = FileProvider.getUriForFile(
672+
this@OSIABWebViewActivity,
673+
"${this@OSIABWebViewActivity.packageName}.fileprovider",
674+
file
675+
)
676+
}
677+
val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply {
678+
putExtra(MediaStore.EXTRA_OUTPUT, currentVideoUri)
679+
}
680+
intentList.add(takeVideoIntent)
681+
}
682+
}
683+
return intentList
684+
}
685+
686+
private fun launchCameraChooser(intentList: List<Intent>) {
687+
val chooser = if (intentList.size == 1) {
688+
intentList[0]
689+
} else {
690+
Intent(Intent.ACTION_CHOOSER).apply {
691+
putExtra(Intent.EXTRA_INTENT, intentList[0])
692+
putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.drop(1).toTypedArray())
693+
}
694+
}
695+
fileChooserLauncher.launch(chooser)
696+
}
697+
698+
private fun launchFullChooser(intentList: List<Intent>, acceptTypes: String, permissionNotDeclaredOrGranted: Boolean) {
699+
val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
700+
addCategory(Intent.CATEGORY_OPENABLE)
701+
type = when {
702+
acceptTypes.contains("video") -> "video/*"
703+
acceptTypes.contains("image") -> "image/*"
704+
else -> "*/*"
705+
}
706+
}
707+
val chooser = Intent(Intent.ACTION_CHOOSER).apply {
708+
putExtra(Intent.EXTRA_INTENT, contentIntent)
709+
if (permissionNotDeclaredOrGranted && intentList.isNotEmpty()) {
710+
putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toTypedArray())
711+
}
712+
}
713+
fileChooserLauncher.launch(chooser)
714+
}
715+
716+
private fun isCameraPermissionGranted(): Boolean {
717+
return ContextCompat.checkSelfPermission(
718+
this@OSIABWebViewActivity, Manifest.permission.CAMERA
719+
) == PackageManager.PERMISSION_GRANTED
720+
}
721+
722+
private fun hasCameraPermissionDeclared(): Boolean {
723+
// The CAMERA permission does not need to be requested unless it is declared in AndroidManifest.xml
724+
// If it's declared, camera intents will throw SecurityException if permission is not granted
725+
try {
726+
val packageManager = this@OSIABWebViewActivity.packageManager
727+
val permissionsInPackage = packageManager.getPackageInfo(
728+
this@OSIABWebViewActivity.packageName,
729+
PackageManager.GET_PERMISSIONS
730+
).requestedPermissions ?: arrayOf()
731+
for (permission in permissionsInPackage) {
732+
if (permission == Manifest.permission.CAMERA) {
733+
return true
734+
}
735+
}
736+
} catch (e: Exception) {
737+
Log.d(LOG_TAG, e.message.toString())
738+
}
739+
return false
740+
}
741+
547742
}
548743

744+
549745
/**
550746
* Clears the WebView cache and removes all cookies if 'clearCache' parameter is 'true'.
551747
* If not, then if 'clearSessionCache' is true, removes the session cookies.

src/main/res/xml/file_paths.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<paths>
3+
<cache-path name="camera" path="." />
4+
</paths>

0 commit comments

Comments
 (0)