diff --git a/CHANGELOG.md b/CHANGELOG.md index b791d96..1a16b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.6.0] + +### Features + +- Allow for photo and video capturing, and filter for media type for file uploads (`onShowFileChooser`) [RMET-4466](https://outsystemsrd.atlassian.net/browse/RMET-4466) + ## [1.5.0] ### Features diff --git a/pom.xml b/pom.xml index e3a1ca3..7d045f2 100644 --- a/pom.xml +++ b/pom.xml @@ -6,5 +6,5 @@ 4.0.0 io.ionic.libs ioninappbrowser-android - 1.5.0 + 1.6.0 diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 6004314..4def0b9 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -29,6 +29,17 @@ android:excludeFromRecents="true" android:windowSoftInputMode="stateAlwaysHidden|adjustPan" /> + + + + + diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/FileProvider.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/FileProvider.kt new file mode 100644 index 0000000..02e0e4a --- /dev/null +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/FileProvider.kt @@ -0,0 +1,3 @@ +package com.outsystems.plugins.inappbrowser.osinappbrowserlib + +class FileProvider: androidx.core.content.FileProvider() \ No newline at end of file diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt index 1128f27..50be009 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/views/OSIABWebViewActivity.kt @@ -2,14 +2,16 @@ package com.outsystems.plugins.inappbrowser.osinappbrowserlib.views import android.Manifest import android.app.Activity +import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.MediaStore import android.util.Log import android.view.Gravity +import android.graphics.Bitmap import android.view.View import android.webkit.CookieManager import android.webkit.GeolocationPermissions @@ -33,6 +35,7 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents @@ -44,7 +47,11 @@ import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABWebView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File import java.io.IOException +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale class OSIABWebViewActivity : AppCompatActivity() { @@ -77,16 +84,37 @@ class OSIABWebViewActivity : AppCompatActivity() { private var geolocationOrigin: String? = null private var wasGeolocationPermissionDenied = false + // used in onShowFileChooser when taking photos or videos + private var currentPhotoFile: File? = null + private var currentPhotoUri: Uri? = null + private var currentVideoFile: File? = null + private var currentVideoUri: Uri? = null + // for file chooser private var filePathCallback: ValueCallback>? = null private val fileChooserLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - filePathCallback?.onReceiveValue( - if (result.resultCode == Activity.RESULT_OK) - WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data) - else null - ) + val uris = when { + result.resultCode != Activity.RESULT_OK -> null + result.data?.data != null -> WebChromeClient.FileChooserParams.parseResult( + result.resultCode, + result.data + ) // file was selected from gallery or file manager, some OEMs also return the video here (e.g. Google) + + // we need to check currentPhotoFile.length() > 0 to make sure a photo was actually taken + currentPhotoUri != null && currentPhotoFile != null && currentPhotoFile!!.length() > 0 -> + arrayOf(currentPhotoUri) // photo capture, since URI is not in data + // we need to check currentVideoFile.length() > 0 to make sure a video was actually taken + currentVideoUri != null && currentVideoFile != null && currentVideoFile!!.length() > 0 -> + arrayOf(currentVideoUri) // fallback for video capture, if video URI is not in data (e.g. Samsung devices) + else -> null + } + filePathCallback?.onReceiveValue(uris) filePathCallback = null + currentPhotoFile = null + currentPhotoUri = null + currentVideoFile = null + currentVideoUri = null } // for back navigation @@ -105,12 +133,21 @@ class OSIABWebViewActivity : AppCompatActivity() { const val ENABLED_ALPHA = 1.0f const val REQUEST_STANDARD_PERMISSION = 622 const val REQUEST_LOCATION_PERMISSION = 623 + const val REQUEST_CAMERA_PERMISSION = 624 const val LOG_TAG = "OSIABWebViewActivity" val errorsToHandle = listOf( WebViewClient.ERROR_HOST_LOOKUP, WebViewClient.ERROR_UNSUPPORTED_SCHEME, WebViewClient.ERROR_BAD_URL ) + + private fun createTempFile(context: Context, prefix: String, suffix: String): File { + val storageDir = context.cacheDir + val formatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.getDefault()) + val timeStamp = LocalDateTime.now().format(formatter) + return File.createTempFile("${prefix}${timeStamp}_", suffix, storageDir) + } + } override fun onCreate(savedInstanceState: Bundle?) { @@ -338,6 +375,18 @@ class OSIABWebViewActivity : AppCompatActivity() { geolocationCallback = null geolocationOrigin = null } + REQUEST_CAMERA_PERMISSION -> { + // permission granted, launch the file chooser + // permission grant is determined in launchFileChooser + try { + filePathCallback?.let { + (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() + } + } catch (e: Exception) { + Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") + (webView.webChromeClient as? OSIABWebChromeClient)?.cancelFileChooser() + } + } } } @@ -502,6 +551,10 @@ class OSIABWebViewActivity : AppCompatActivity() { */ private inner class OSIABWebChromeClient : WebChromeClient() { + // for handling uploads (photo, video, gallery, files) + private var acceptTypes: String = "" + private var captureEnabled: Boolean = false + // handle standard permissions (e.g. audio, camera) override fun onPermissionRequest(request: PermissionRequest?) { request?.let { @@ -521,31 +574,174 @@ class OSIABWebViewActivity : AppCompatActivity() { // handle opening the file chooser within the WebView override fun onShowFileChooser( - webView: WebView?, - filePathCallback: ValueCallback>?, - fileChooserParams: FileChooserParams? + webView: WebView, + filePathCallback: ValueCallback>, + fileChooserParams: FileChooserParams ): Boolean { this@OSIABWebViewActivity.filePathCallback = filePathCallback - val intent = fileChooserParams?.createIntent() + acceptTypes = fileChooserParams.acceptTypes.joinToString() + captureEnabled = fileChooserParams.isCaptureEnabled + + // if camera permission is declared in manifest but is not granted, request it + if (hasCameraPermissionDeclared() && !isCameraPermissionGranted()) { + ActivityCompat.requestPermissions( + this@OSIABWebViewActivity, + arrayOf(Manifest.permission.CAMERA), + REQUEST_CAMERA_PERMISSION + ) + // don’t launch chooser yet, wait for permission result + return true + } + try { - fileChooserLauncher.launch(intent!!) + launchFileChooser(acceptTypes, captureEnabled) + return true } catch (npe: NullPointerException) { - this@OSIABWebViewActivity.filePathCallback = null Log.e( LOG_TAG, "Attempted to launch but intent is null; fileChooserParams=$fileChooserParams", npe ) + cancelFileChooser() return false } catch (e: Exception) { - this@OSIABWebViewActivity.filePathCallback = null Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") + cancelFileChooser() return false } - return true } + + fun cancelFileChooser() { + filePathCallback?.onReceiveValue(null) + filePathCallback = null + acceptTypes = "" + captureEnabled = false + } + + fun retryFileChooser() { + try { + launchFileChooser(acceptTypes, captureEnabled) + } catch (e: Exception) { + e.printStackTrace() + cancelFileChooser() + } + acceptTypes = "" + captureEnabled = false + } + + private fun launchFileChooser(acceptTypes: String = "", isCaptureEnabled: Boolean = false) { + val intentList = buildPhotoVideoIntents(acceptTypes) + val permissionNotDeclaredOrGranted = hasCameraPermissionDeclared().not() || isCameraPermissionGranted() + + if (isCaptureEnabled && permissionNotDeclaredOrGranted) { + // if capture is enabled, we only show the camera and video options + launchCameraChooser(intentList) + } else if (!isCaptureEnabled) { + // if capture is not enabled, we show the full chooser + launchFullChooser(intentList, acceptTypes, permissionNotDeclaredOrGranted) + } else { + // capture is enabled but permission declared and not granted, + // as our only option is to capture, we cannot proceed + cancelFileChooser() + return + } + } + + private fun buildPhotoVideoIntents(acceptTypes: String): MutableList { + val intentList = mutableListOf() + val permissionNotDeclaredOrGranted = hasCameraPermissionDeclared().not() || isCameraPermissionGranted() + + if (permissionNotDeclaredOrGranted) { + if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { + currentPhotoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg").also { file -> + currentPhotoUri = FileProvider.getUriForFile( + this@OSIABWebViewActivity, + "${this@OSIABWebViewActivity.packageName}.fileprovider", + file + ) + } + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri) + } + intentList.add(takePictureIntent) + } + if (acceptTypes.contains("video") || acceptTypes.isEmpty()) { + currentVideoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4").also { file -> + currentVideoFile = file + currentVideoUri = FileProvider.getUriForFile( + this@OSIABWebViewActivity, + "${this@OSIABWebViewActivity.packageName}.fileprovider", + file + ) + } + val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, currentVideoUri) + } + intentList.add(takeVideoIntent) + } + } + return intentList + } + + private fun launchCameraChooser(intentList: List) { + val chooser = if (intentList.size == 1) { + intentList[0] + } else { + Intent(Intent.ACTION_CHOOSER).apply { + putExtra(Intent.EXTRA_INTENT, intentList[0]) + putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.drop(1).toTypedArray()) + } + } + fileChooserLauncher.launch(chooser) + } + + private fun launchFullChooser(intentList: List, acceptTypes: String, permissionNotDeclaredOrGranted: Boolean) { + val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = when { + acceptTypes.contains("video") -> "video/*" + acceptTypes.contains("image") -> "image/*" + else -> "*/*" + } + } + val chooser = Intent(Intent.ACTION_CHOOSER).apply { + putExtra(Intent.EXTRA_INTENT, contentIntent) + if (permissionNotDeclaredOrGranted && intentList.isNotEmpty()) { + putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toTypedArray()) + } + } + fileChooserLauncher.launch(chooser) + } + + private fun isCameraPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + this@OSIABWebViewActivity, Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + } + + private fun hasCameraPermissionDeclared(): Boolean { + // The CAMERA permission does not need to be requested unless it is declared in AndroidManifest.xml + // If it's declared, camera intents will throw SecurityException if permission is not granted + try { + val packageManager = this@OSIABWebViewActivity.packageManager + val permissionsInPackage = packageManager.getPackageInfo( + this@OSIABWebViewActivity.packageName, + PackageManager.GET_PERMISSIONS + ).requestedPermissions ?: arrayOf() + for (permission in permissionsInPackage) { + if (permission == Manifest.permission.CAMERA) { + return true + } + } + } catch (e: Exception) { + Log.d(LOG_TAG, e.message.toString()) + } + return false + } + } + /** * Clears the WebView cache and removes all cookies if 'clearCache' parameter is 'true'. * If not, then if 'clearSessionCache' is true, removes the session cookies. diff --git a/src/main/res/xml/file_paths.xml b/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bab5e9b --- /dev/null +++ b/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file