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