Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
33e99a8
test: add unit tests for OSIABPdfHelper
andredestro Aug 14, 2025
4b6aa0a
test: first version of an implementation to allow upload of photos or…
alexgerardojacinto Aug 28, 2025
a0a6c2f
Merge branch 'main' into feat/twebview-take-photo-video-gallery
alexgerardojacinto Aug 28, 2025
1d2e68e
feat: use fileChooseParams to determine if user should be able to tak…
alexgerardojacinto Aug 28, 2025
0cc3f4a
feat: implement requesting the permission for the camera if needed
alexgerardojacinto Sep 3, 2025
dcfca2d
feat: if permission to the camera is denied, we should still present …
alexgerardojacinto Sep 4, 2025
5f43881
feat: when `capture` is passed, only give camera options, otherwise, …
alexgerardojacinto Sep 4, 2025
935f7e6
chore: small refactors
alexgerardojacinto Sep 4, 2025
48d5119
fix: remove unnecessary URI permission grants
alexgerardojacinto Sep 5, 2025
b04eb84
refactor: multiple refactors to make the code more readable and cleaner
alexgerardojacinto Sep 5, 2025
c74b4bb
chore: refactor comments
alexgerardojacinto Sep 5, 2025
0bb0486
fix: some devices (e.g. Samsung) don't return video in results.data, …
alexgerardojacinto Sep 5, 2025
a590bb4
chore: refactor comment placement
alexgerardojacinto Sep 5, 2025
ddbfa34
chore: update changelog
alexgerardojacinto Sep 5, 2025
aa8715f
chore: remove extra lines
alexgerardojacinto Sep 5, 2025
c0b3194
chore: add comment explaining usage of FileProvider
alexgerardojacinto Sep 5, 2025
8ba9fd8
chore: update lib version to 1.6.0
alexgerardojacinto Sep 5, 2025
a62b048
Merge remote-tracking branch 'origin/feat/twebview-take-photo-video-g…
alexgerardojacinto Sep 5, 2025
af05759
refactor: remove unnecessary if/else
alexgerardojacinto Sep 8, 2025
c3251dc
feat: only request camera permission if it is declared in AndroidMani…
alexgerardojacinto Sep 8, 2025
0a05230
refactor: use more human readable format for file names
alexgerardojacinto Sep 8, 2025
c1f6a4b
refactor: no need to duplicate variables
alexgerardojacinto Sep 8, 2025
aaa3d41
refactor: remove unnecessary comments
alexgerardojacinto Sep 8, 2025
6516ba5
refactor: split launchFileChooser in multiple functions for readability
alexgerardojacinto Sep 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.ionic.libs</groupId>
<artifactId>ioninappbrowser-android</artifactId>
<version>1.5.0</version>
<version>1.6.0</version>
</project>
11 changes: 11 additions & 0 deletions src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@
android:excludeFromRecents="true"
android:windowSoftInputMode="stateAlwaysHidden|adjustPan" />

<!-- Used for when taking photos/videos when uploading files through WebChromeClient.onShowFileChooser -->
<provider
android:name="com.outsystems.plugins.inappbrowser.osinappbrowserlib.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>

</application>

<queries>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.outsystems.plugins.inappbrowser.osinappbrowserlib

class FileProvider: androidx.core.content.FileProvider()
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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() {

Expand Down Expand Up @@ -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<Array<Uri>>? = 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
Expand All @@ -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?) {
Expand Down Expand Up @@ -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()
}
}
}
}

Expand Down Expand Up @@ -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 {
Expand All @@ -521,31 +574,174 @@ class OSIABWebViewActivity : AppCompatActivity() {

// handle opening the file chooser within the WebView
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
webView: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
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<Intent> {
val intentList = mutableListOf<Intent>()
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<Intent>) {
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<Intent>, 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.
Expand Down
4 changes: 4 additions & 0 deletions src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="camera" path="." />
</paths>
Loading