From 33e99a890576cb1cb77ecb9bff18750142d25774 Mon Sep 17 00:00:00 2001 From: Andre Destro Date: Thu, 14 Aug 2025 15:43:02 +0100 Subject: [PATCH 01/22] test: add unit tests for OSIABPdfHelper --- build.gradle | 1 + .../helpers/OSIABPdfHelper.kt | 12 +- .../views/OSIABWebViewActivity.kt | 1 - .../helpers/OSIABPdfHelperTest.kt | 208 ++++++++++++++++++ 4 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelperTest.kt diff --git a/build.gradle b/build.gradle index f0dde17..ae20da6 100644 --- a/build.gradle +++ b/build.gradle @@ -135,6 +135,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' testImplementation 'org.robolectric:robolectric:4.12.2' testImplementation 'org.mockito:mockito-core:5.12.0' + testImplementation "io.mockk:mockk:1.13.10" testImplementation 'androidx.test:core:1.6.1' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' } diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt index 9339696..d73feb6 100644 --- a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt +++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelper.kt @@ -7,6 +7,14 @@ import java.net.HttpURLConnection import java.net.URL object OSIABPdfHelper { + + interface UrlFactory { + fun create(url: String): URL + } + + private class DefaultUrlFactory : UrlFactory { + override fun create(url: String): URL = URL(url) + } fun isContentTypeApplicationPdf(urlString: String): Boolean { return try { @@ -23,10 +31,10 @@ object OSIABPdfHelper { } } - fun checkPdfByRequest(urlString: String, method: String): Boolean { + fun checkPdfByRequest(urlString: String, method: String, urlFactory: UrlFactory = DefaultUrlFactory()): Boolean { var conn: HttpURLConnection? = null return try { - conn = (URL(urlString).openConnection() as? HttpURLConnection) + conn = (urlFactory.create(urlString).openConnection() as? HttpURLConnection) conn?.run { instanceFollowRedirects = true requestMethod = method 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 22852cc..adab468 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 @@ -379,7 +379,6 @@ class OSIABWebViewActivity : AppCompatActivity() { } } - // set back to false so that the next successful load // if the load fails, onReceivedError takes care of setting it back to true hasLoadError = false diff --git a/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelperTest.kt b/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelperTest.kt new file mode 100644 index 0000000..60bfcbf --- /dev/null +++ b/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABPdfHelperTest.kt @@ -0,0 +1,208 @@ +package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.HttpURLConnection +import java.net.ServerSocket +import java.net.Socket +import java.net.URL +import java.nio.file.Files +import kotlin.concurrent.thread + +class OSIABPdfHelperTest { + + @Test + fun `isContentTypeApplicationPdf returns true if HEAD is PDF`() { + mockkObject(OSIABPdfHelper) + every { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } returns true + + val result = OSIABPdfHelper.isContentTypeApplicationPdf("http://example.com") + + assertTrue(result) + verify { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } + verify(exactly = 0) { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } + unmockkObject(OSIABPdfHelper) + } + + @Test + fun `isContentTypeApplicationPdf falls back to GET if HEAD fails`() { + mockkObject(OSIABPdfHelper) + every { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } returns false + every { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } returns true + + val result = OSIABPdfHelper.isContentTypeApplicationPdf("http://example.com") + + assertTrue(result) + verify { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } + verify { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } + unmockkObject(OSIABPdfHelper) + } + + @Test + fun `isContentTypeApplicationPdf returns false if both HEAD and GET fail`() { + mockkObject(OSIABPdfHelper) + every { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } returns false + every { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } returns false + + val result = OSIABPdfHelper.isContentTypeApplicationPdf("http://example.com") + + assertFalse(result) + verify { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } + verify { OSIABPdfHelper.checkPdfByRequest(any(), "GET", any()) } + unmockkObject(OSIABPdfHelper) + } + + @Test + fun `isContentTypeApplicationPdf returns false if exception occurs`() { + mockkObject(OSIABPdfHelper) + every { + OSIABPdfHelper.checkPdfByRequest( + any(), + any(), + any() + ) + } throws RuntimeException("Network error") + + val result = OSIABPdfHelper.isContentTypeApplicationPdf("http://example.com") + + assertFalse(result) + verify { OSIABPdfHelper.checkPdfByRequest(any(), "HEAD", any()) } + unmockkObject(OSIABPdfHelper) + } + + @Test + fun `returns true when content type is application_pdf`() { + val urlFactory = mockk() + val url = mockk() + val conn = mockk(relaxed = true) + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns conn + every { conn.contentType } returns "application/pdf" + every { conn.connect() } returns Unit + + val result = OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + + assertTrue(result) + verify { conn.connect() } + verify { conn.disconnect() } + } + + @Test + fun `returns true when disposition header contains pdf and content type is empty`() { + val urlFactory = mockk() + val url = mockk() + val conn = mockk(relaxed = true) + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns conn + every { conn.contentType } returns null + every { conn.getHeaderField("Content-Disposition") } returns "attachment; filename=test.pdf" + every { conn.connect() } returns Unit + + val result = OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + + assertTrue(result) + verify { conn.connect() } + verify { conn.disconnect() } + } + + @Test + fun `returns false when neither content type nor disposition indicate pdf`() { + val urlFactory = mockk() + val url = mockk() + val conn = mockk(relaxed = true) + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns conn + every { conn.contentType } returns "text/html" + every { conn.getHeaderField("Content-Disposition") } returns "inline" + every { conn.connect() } returns Unit + + val result = OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + + assertFalse(result) + verify { conn.connect() } + verify { conn.disconnect() } + } + + @Test + fun `sets Range header for GET method`() { + val urlFactory = mockk() + val url = mockk() + val conn = mockk(relaxed = true) + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns conn + every { conn.contentType } returns "application/pdf" + every { conn.connect() } returns Unit + + OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "GET", urlFactory) + + verify { conn.setRequestProperty("Range", "bytes=0-0") } + verify { conn.connect() } + verify { conn.disconnect() } + } + + @Test + fun `returns false if connection is null`() { + val urlFactory = mockk() + val url = mockk() + every { urlFactory.create(any()) } returns url + every { url.openConnection() } returns null + + val result = OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + + assertFalse(result) + } + + @Test + fun `returns false if exception is thrown`() { + val urlFactory = mockk() + every { urlFactory.create(any()) } throws RuntimeException("Network error") + + val result = try { + OSIABPdfHelper.checkPdfByRequest("http://example.com/test.pdf", "HEAD", urlFactory) + } catch (_: Exception) { + false + } + + assertFalse(result) + } + + @Test + fun `downloadPdfToCache creates file with content`() { + val server = ServerSocket(0) + val port = server.localPort + val pdfBytes = "%PDF-1.4 test".toByteArray() + thread { + val client: Socket = server.accept() + val out = client.getOutputStream() + out.write( + ("HTTP/1.1 200 OK\r\n" + + "Content-Type: application/pdf\r\n" + + "Content-Length: ${pdfBytes.size}\r\n" + + "\r\n").toByteArray() + ) + out.write(pdfBytes) + out.flush() + client.close() + server.close() + } + + val context = mockk() + val cacheDir = Files.createTempDirectory("test_cache").toFile() + every { context.cacheDir } returns cacheDir + + val url = "http://localhost:$port/test.pdf" + val file = OSIABPdfHelper.downloadPdfToCache(context, url) + + assertTrue(file.exists()) + assertTrue(file.readBytes().copyOfRange(0, 8).contentEquals("%PDF-1.4".toByteArray())) + file.delete() + cacheDir.deleteRecursively() + } +} From 4b6aa0a2f815bfe19e02c546f990c8825539c6df Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Thu, 28 Aug 2025 15:02:01 -0300 Subject: [PATCH 02/22] test: first version of an implementation to allow upload of photos or videos taking them with the camera, or any other files --- src/main/AndroidManifest.xml | 10 ++ .../osinappbrowserlib/FileProvider.kt | 3 + .../views/OSIABWebViewActivity.kt | 108 +++++++++++++++++- src/main/res/xml/file_paths.xml | 4 + 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/FileProvider.kt create mode 100644 src/main/res/xml/file_paths.xml diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 6004314..8d8e88c 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -29,6 +29,16 @@ 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 adab468..81170d0 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,17 @@ 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.os.Environment +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 +36,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,6 +48,7 @@ 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 class OSIABWebViewActivity : AppCompatActivity() { @@ -81,12 +86,31 @@ class OSIABWebViewActivity : AppCompatActivity() { 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 ) filePathCallback = null + */ + + val uris = when { + result.resultCode != Activity.RESULT_OK -> null + result.data?.data != null -> WebChromeClient.FileChooserParams.parseResult( + result.resultCode, + result.data + ) // gallery + currentPhotoUri != null -> arrayOf(currentPhotoUri) // photo capture + currentVideoUri != null -> arrayOf(currentVideoUri) // video capture + else -> null + } + + filePathCallback?.onReceiveValue(uris) + filePathCallback = null + currentPhotoUri = null + currentVideoUri = null + } // for back navigation @@ -97,6 +121,9 @@ class OSIABWebViewActivity : AppCompatActivity() { // and to send the correct URL in the browserPageNavigationCompleted event private var originalUrl: String? = null + private var currentPhotoUri: Uri? = null + private var currentVideoUri: Uri? = null + companion object { const val WEB_VIEW_URL_EXTRA = "WEB_VIEW_URL_EXTRA" const val WEB_VIEW_OPTIONS_EXTRA = "WEB_VIEW_OPTIONS_EXTRA" @@ -111,6 +138,23 @@ class OSIABWebViewActivity : AppCompatActivity() { WebViewClient.ERROR_UNSUPPORTED_SCHEME, WebViewClient.ERROR_BAD_URL ) + + private fun createTempFile(context: Context, prefix: String, suffix: String): File { + val storageDir = context.cacheDir + return File.createTempFile("${prefix}${System.currentTimeMillis()}_", suffix, storageDir) + } + + private fun grantUriPermissions(context: Context, intent: Intent, uri: Uri) { + val resInfoList = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + for (resolveInfo in resInfoList) { + context.grantUriPermission( + resolveInfo.activityInfo.packageName, + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + } override fun onCreate(savedInstanceState: Bundle?) { @@ -514,12 +558,68 @@ class OSIABWebViewActivity : AppCompatActivity() { override fun onShowFileChooser( webView: WebView?, filePathCallback: ValueCallback>?, - fileChooserParams: FileChooserParams? + fileChooserParams: FileChooserParams ): Boolean { this@OSIABWebViewActivity.filePathCallback = filePathCallback - val intent = fileChooserParams?.createIntent() + + val acceptTypes = fileChooserParams.acceptTypes.joinToString() + val intentList = mutableListOf() + + // --- Photo capture --- + if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { + val photoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg") + currentPhotoUri = FileProvider.getUriForFile( + this@OSIABWebViewActivity, + "${this@OSIABWebViewActivity.packageName}.fileprovider", + photoFile + ) + + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + grantUriPermissions(this@OSIABWebViewActivity, takePictureIntent, currentPhotoUri!!) + intentList.add(takePictureIntent) + } + + // --- Video capture --- + if (acceptTypes.contains("video") || acceptTypes.isEmpty()) { + val videoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4") + currentVideoUri = FileProvider.getUriForFile( + this@OSIABWebViewActivity, + "${this@OSIABWebViewActivity.packageName}.fileprovider", + videoFile + ) + + val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, currentVideoUri) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + grantUriPermissions(this@OSIABWebViewActivity, takeVideoIntent, currentVideoUri!!) + intentList.add(takeVideoIntent) + } + + // --- Gallery picker --- + val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = when { + acceptTypes.contains("video") -> "video/*" + acceptTypes.contains("image") -> "image/*" + else -> "*/*" + } + } + + // --- Chooser --- + val chooserIntent = Intent(Intent.ACTION_CHOOSER).apply { + putExtra(Intent.EXTRA_INTENT, contentIntent) + putExtra(Intent.EXTRA_TITLE, "Select or capture") + putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toTypedArray()) + } + + val intent = fileChooserParams.createIntent() try { - fileChooserLauncher.launch(intent!!) + //fileChooserLauncher.launch(intent!!) + fileChooserLauncher.launch(chooserIntent) } catch (npe: NullPointerException) { this@OSIABWebViewActivity.filePathCallback = null Log.e( 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 From 1d2e68e77f80d57a8a6c17392df9f56cfb7981e3 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Thu, 28 Aug 2025 15:20:55 -0300 Subject: [PATCH 03/22] feat: use fileChooseParams to determine if user should be able to take photo or record video --- .../views/OSIABWebViewActivity.kt | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) 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 0d0d471..46ec767 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 @@ -574,38 +574,40 @@ class OSIABWebViewActivity : AppCompatActivity() { val acceptTypes = fileChooserParams.acceptTypes.joinToString() val intentList = mutableListOf() - // --- Photo capture --- - if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { - val photoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg") - currentPhotoUri = FileProvider.getUriForFile( - this@OSIABWebViewActivity, - "${this@OSIABWebViewActivity.packageName}.fileprovider", - photoFile - ) - - val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { - putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (fileChooserParams.isCaptureEnabled) { + // --- Photo capture --- + if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { + val photoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg") + currentPhotoUri = FileProvider.getUriForFile( + this@OSIABWebViewActivity, + "${this@OSIABWebViewActivity.packageName}.fileprovider", + photoFile + ) + + val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + grantUriPermissions(this@OSIABWebViewActivity, takePictureIntent, currentPhotoUri!!) + intentList.add(takePictureIntent) } - grantUriPermissions(this@OSIABWebViewActivity, takePictureIntent, currentPhotoUri!!) - intentList.add(takePictureIntent) - } - // --- Video capture --- - if (acceptTypes.contains("video") || acceptTypes.isEmpty()) { - val videoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4") - currentVideoUri = FileProvider.getUriForFile( - this@OSIABWebViewActivity, - "${this@OSIABWebViewActivity.packageName}.fileprovider", - videoFile - ) - - val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply { - putExtra(MediaStore.EXTRA_OUTPUT, currentVideoUri) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + // --- Video capture --- + if (acceptTypes.contains("video") || acceptTypes.isEmpty()) { + val videoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4") + currentVideoUri = FileProvider.getUriForFile( + this@OSIABWebViewActivity, + "${this@OSIABWebViewActivity.packageName}.fileprovider", + videoFile + ) + + val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply { + putExtra(MediaStore.EXTRA_OUTPUT, currentVideoUri) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + grantUriPermissions(this@OSIABWebViewActivity, takeVideoIntent, currentVideoUri!!) + intentList.add(takeVideoIntent) } - grantUriPermissions(this@OSIABWebViewActivity, takeVideoIntent, currentVideoUri!!) - intentList.add(takeVideoIntent) } // --- Gallery picker --- From 0cc3f4a2df0d6b7cff3fe82579d997adf2b0d54f Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Wed, 3 Sep 2025 17:49:14 -0300 Subject: [PATCH 04/22] feat: implement requesting the permission for the camera if needed References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 109 +++++++++++++++--- 1 file changed, 90 insertions(+), 19 deletions(-) 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 46ec767..19afbdd 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 @@ -132,6 +132,7 @@ class OSIABWebViewActivity : AppCompatActivity() { const val ENABLED_ALPHA = 1.0f const val REQUEST_STANDARD_PERMISSION = 622 const val REQUEST_LOCATION_PERMISSION = 623 + const val CAMERA_PERMISSION_REQUEST_CODE = 824 const val LOG_TAG = "OSIABWebViewActivity" val errorsToHandle = listOf( WebViewClient.ERROR_HOST_LOOKUP, @@ -382,6 +383,26 @@ class OSIABWebViewActivity : AppCompatActivity() { geolocationCallback = null geolocationOrigin = null } + CAMERA_PERMISSION_REQUEST_CODE -> { + val granted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } + if (granted) { + // Permission granted, launch the file chooser + try { + filePathCallback?.let { + //launchFileChooser("", true) + //webView.webChromeClient.retryFileChooser() + (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() //TODO not the best way of calling this method + } + } catch (e: Exception) { + Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") + } + } else { + // Permission denied, notify the WebView + //filePathCallback?.onReceiveValue(null) + //filePathCallback = null + (webView.webChromeClient as? OSIABWebChromeClient)?.cancelFileChooser() + } + } } } @@ -546,6 +567,9 @@ class OSIABWebViewActivity : AppCompatActivity() { */ private inner class OSIABWebChromeClient : WebChromeClient() { + private var pendingAcceptTypes: String = "" + private var pendingCaptureEnabled: Boolean = false + // handle standard permissions (e.g. audio, camera) override fun onPermissionRequest(request: PermissionRequest?) { request?.let { @@ -569,12 +593,74 @@ class OSIABWebViewActivity : AppCompatActivity() { filePathCallback: ValueCallback>?, fileChooserParams: FileChooserParams ): Boolean { + this@OSIABWebViewActivity.filePathCallback = filePathCallback val acceptTypes = fileChooserParams.acceptTypes.joinToString() + val captureEnabled = fileChooserParams.isCaptureEnabled + pendingAcceptTypes = acceptTypes + pendingCaptureEnabled = captureEnabled + + // If camera is needed and not granted -> request permission + if (fileChooserParams.isCaptureEnabled && + ContextCompat.checkSelfPermission(this@OSIABWebViewActivity, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this@OSIABWebViewActivity, + arrayOf(Manifest.permission.CAMERA), + CAMERA_PERMISSION_REQUEST_CODE + ) + // Don’t launch chooser yet — wait for permission result + return true + } + + try { + //fileChooserLauncher.launch(intent!!) + //fileChooserLauncher.launch(chooserIntent) + 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 + } + } + + fun cancelFileChooser() { + filePathCallback?.onReceiveValue(null) + filePathCallback = null + pendingAcceptTypes = "" + pendingCaptureEnabled = false + } + + fun retryFileChooser() { + + try { + launchFileChooser(pendingAcceptTypes, pendingCaptureEnabled) + } catch (e: Exception) { + e.printStackTrace() + cancelFileChooser() + } + pendingAcceptTypes = "" + pendingCaptureEnabled = false + } + + private fun launchFileChooser(acceptTypes: String = "", isCaptureEnabled: Boolean = false) { val intentList = mutableListOf() - if (fileChooserParams.isCaptureEnabled) { + if (isCaptureEnabled) { + // --- Photo capture --- if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { val photoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg") @@ -627,27 +713,12 @@ class OSIABWebViewActivity : AppCompatActivity() { putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toTypedArray()) } - val intent = fileChooserParams.createIntent() - try { - //fileChooserLauncher.launch(intent!!) - fileChooserLauncher.launch(chooserIntent) - } catch (npe: NullPointerException) { - this@OSIABWebViewActivity.filePathCallback = null - Log.e( - LOG_TAG, - "Attempted to launch but intent is null; fileChooserParams=$fileChooserParams", - npe - ) - return false - } catch (e: Exception) { - this@OSIABWebViewActivity.filePathCallback = null - Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") - return false - } - return true + fileChooserLauncher.launch(chooserIntent) } + } + /** * Clears the WebView cache and removes all cookies if 'clearCache' parameter is 'true'. * If not, then if 'clearSessionCache' is true, removes the session cookies. From dcfca2d0c877c33bd1b0f7110fde0dcc7c4a7ce7 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Thu, 4 Sep 2025 09:22:04 -0300 Subject: [PATCH 05/22] feat: if permission to the camera is denied, we should still present the file chooser without camera options References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) 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 19afbdd..0c1c368 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 @@ -82,6 +82,10 @@ class OSIABWebViewActivity : AppCompatActivity() { private var geolocationOrigin: String? = null private var wasGeolocationPermissionDenied = false + // for handling uploads (photo, video, gallery, files) + private var pendingAcceptTypes: String = "" + private var pendingCaptureEnabled: Boolean = false + // for file chooser private var filePathCallback: ValueCallback>? = null private val fileChooserLauncher = @@ -395,12 +399,21 @@ class OSIABWebViewActivity : AppCompatActivity() { } } catch (e: Exception) { Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") + (webView.webChromeClient as? OSIABWebChromeClient)?.cancelFileChooser() } } else { - // Permission denied, notify the WebView - //filePathCallback?.onReceiveValue(null) - //filePathCallback = null - (webView.webChromeClient as? OSIABWebChromeClient)?.cancelFileChooser() + // Permission denied, try file chooser without camera + try { + filePathCallback?.let { + //launchFileChooser("", true) + //webView.webChromeClient.retryFileChooser() + pendingCaptureEnabled = false + (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() //TODO not the best way of calling this method + } + } catch (e: Exception) { + Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") + (webView.webChromeClient as? OSIABWebChromeClient)?.cancelFileChooser() + } } } } @@ -567,9 +580,6 @@ class OSIABWebViewActivity : AppCompatActivity() { */ private inner class OSIABWebChromeClient : WebChromeClient() { - private var pendingAcceptTypes: String = "" - private var pendingCaptureEnabled: Boolean = false - // handle standard permissions (e.g. audio, camera) override fun onPermissionRequest(request: PermissionRequest?) { request?.let { @@ -645,7 +655,6 @@ class OSIABWebViewActivity : AppCompatActivity() { } fun retryFileChooser() { - try { launchFileChooser(pendingAcceptTypes, pendingCaptureEnabled) } catch (e: Exception) { From 5f4388113df7bf6323cb5c8bd1c6744f7af16781 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Thu, 4 Sep 2025 16:13:10 -0300 Subject: [PATCH 06/22] feat: when `capture` is passed, only give camera options, otherwise, give all the options References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 83 +++++++++++-------- 1 file changed, 50 insertions(+), 33 deletions(-) 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 0c1c368..9c23353 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 @@ -82,10 +82,6 @@ class OSIABWebViewActivity : AppCompatActivity() { private var geolocationOrigin: String? = null private var wasGeolocationPermissionDenied = false - // for handling uploads (photo, video, gallery, files) - private var pendingAcceptTypes: String = "" - private var pendingCaptureEnabled: Boolean = false - // for file chooser private var filePathCallback: ValueCallback>? = null private val fileChooserLauncher = @@ -393,8 +389,6 @@ class OSIABWebViewActivity : AppCompatActivity() { // Permission granted, launch the file chooser try { filePathCallback?.let { - //launchFileChooser("", true) - //webView.webChromeClient.retryFileChooser() (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() //TODO not the best way of calling this method } } catch (e: Exception) { @@ -405,9 +399,7 @@ class OSIABWebViewActivity : AppCompatActivity() { // Permission denied, try file chooser without camera try { filePathCallback?.let { - //launchFileChooser("", true) - //webView.webChromeClient.retryFileChooser() - pendingCaptureEnabled = false + //pendingCaptureEnabled = false (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() //TODO not the best way of calling this method } } catch (e: Exception) { @@ -580,6 +572,10 @@ class OSIABWebViewActivity : AppCompatActivity() { */ private inner class OSIABWebChromeClient : WebChromeClient() { + // for handling uploads (photo, video, gallery, files) + private var pendingAcceptTypes: String = "" + private var pendingCaptureEnabled: Boolean = false + // handle standard permissions (e.g. audio, camera) override fun onPermissionRequest(request: PermissionRequest?) { request?.let { @@ -612,9 +608,8 @@ class OSIABWebViewActivity : AppCompatActivity() { pendingCaptureEnabled = captureEnabled // If camera is needed and not granted -> request permission - if (fileChooserParams.isCaptureEnabled && - ContextCompat.checkSelfPermission(this@OSIABWebViewActivity, Manifest.permission.CAMERA) - != PackageManager.PERMISSION_GRANTED + if (ContextCompat.checkSelfPermission( + this@OSIABWebViewActivity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this@OSIABWebViewActivity, @@ -626,12 +621,9 @@ class OSIABWebViewActivity : AppCompatActivity() { } try { - //fileChooserLauncher.launch(intent!!) - //fileChooserLauncher.launch(chooserIntent) 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", @@ -640,7 +632,6 @@ class OSIABWebViewActivity : AppCompatActivity() { cancelFileChooser() return false } catch (e: Exception) { - //this@OSIABWebViewActivity.filePathCallback = null Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") cancelFileChooser() return false @@ -667,9 +658,10 @@ class OSIABWebViewActivity : AppCompatActivity() { private fun launchFileChooser(acceptTypes: String = "", isCaptureEnabled: Boolean = false) { val intentList = mutableListOf() + val permissionGranted = ContextCompat.checkSelfPermission( + this@OSIABWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED - if (isCaptureEnabled) { - + if (permissionGranted) { // --- Photo capture --- if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { val photoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg") @@ -703,26 +695,51 @@ class OSIABWebViewActivity : AppCompatActivity() { grantUriPermissions(this@OSIABWebViewActivity, takeVideoIntent, currentVideoUri!!) intentList.add(takeVideoIntent) } + } - // --- Gallery picker --- - val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = when { - acceptTypes.contains("video") -> "video/*" - acceptTypes.contains("image") -> "image/*" - else -> "*/*" + if (isCaptureEnabled && permissionGranted) { + // Only camera intents (no gallery) + val chooser = if (intentList.size == 1) { + intentList[0] // single option launch directly + } else { + Intent(Intent.ACTION_CHOOSER).apply { + putExtra(Intent.EXTRA_INTENT, intentList[0]) + if (intentList.size > 1) { + putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.drop(1).toTypedArray()) + } + } + } + fileChooserLauncher.launch(chooser) + } else if (!isCaptureEnabled) { + // if capture is not enabled, we always show the full chooser + // if capture is enabled, we only want to show camera options (photo and video) + // so if capture is enabled but permission is not granted, we don't do anything + // Normal flow with gallery/file picker + val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = when { + acceptTypes.contains("video") -> "video/*" + acceptTypes.contains("image") -> "image/*" + else -> "*/*" + } } - } - // --- Chooser --- - val chooserIntent = Intent(Intent.ACTION_CHOOSER).apply { - putExtra(Intent.EXTRA_INTENT, contentIntent) - putExtra(Intent.EXTRA_TITLE, "Select or capture") - putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toTypedArray()) + val chooser = Intent(Intent.ACTION_CHOOSER).apply { + putExtra(Intent.EXTRA_INTENT, contentIntent) + if (permissionGranted) { + if (intentList.isNotEmpty()) { + putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toTypedArray()) + } + } + } + fileChooserLauncher.launch(chooser) + } else { + // capture enabled but permission not granted + // as our only option is to capture, we can't do anything + cancelFileChooser() + return } - - fileChooserLauncher.launch(chooserIntent) } } From 935f7e6f1eb5fb47c6ae6b8eced7016c96ed8bdd Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Thu, 4 Sep 2025 16:27:57 -0300 Subject: [PATCH 07/22] chore: small refactors References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../osinappbrowserlib/views/OSIABWebViewActivity.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 9c23353..939dd96 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 @@ -82,6 +82,10 @@ class OSIABWebViewActivity : AppCompatActivity() { private var geolocationOrigin: String? = null private var wasGeolocationPermissionDenied = false + // used in onShowFileChooser when taking photos or videos + private var currentPhotoUri: Uri? = null + private var currentVideoUri: Uri? = null + // for file chooser private var filePathCallback: ValueCallback>? = null private val fileChooserLauncher = @@ -121,9 +125,6 @@ class OSIABWebViewActivity : AppCompatActivity() { // and to send the correct URL in the browserPageNavigationCompleted event private var originalUrl: String? = null - private var currentPhotoUri: Uri? = null - private var currentVideoUri: Uri? = null - companion object { const val WEB_VIEW_URL_EXTRA = "WEB_VIEW_URL_EXTRA" const val WEB_VIEW_OPTIONS_EXTRA = "WEB_VIEW_OPTIONS_EXTRA" @@ -389,7 +390,7 @@ class OSIABWebViewActivity : AppCompatActivity() { // Permission granted, launch the file chooser try { filePathCallback?.let { - (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() //TODO not the best way of calling this method + (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() } } catch (e: Exception) { Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") @@ -399,8 +400,7 @@ class OSIABWebViewActivity : AppCompatActivity() { // Permission denied, try file chooser without camera try { filePathCallback?.let { - //pendingCaptureEnabled = false - (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() //TODO not the best way of calling this method + (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() } } catch (e: Exception) { Log.d(LOG_TAG, "Error launching file chooser. Exception: ${e.message}") From 48d51198693f71dc57120bf5aee99f4ab6424110 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Fri, 5 Sep 2025 11:18:40 -0300 Subject: [PATCH 08/22] fix: remove unnecessary URI permission grants References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) 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 939dd96..deb4ce7 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 @@ -146,17 +146,6 @@ class OSIABWebViewActivity : AppCompatActivity() { return File.createTempFile("${prefix}${System.currentTimeMillis()}_", suffix, storageDir) } - private fun grantUriPermissions(context: Context, intent: Intent, uri: Uri) { - val resInfoList = context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) - for (resolveInfo in resInfoList) { - context.grantUriPermission( - resolveInfo.activityInfo.packageName, - uri, - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - } - } override fun onCreate(savedInstanceState: Bundle?) { @@ -607,7 +596,7 @@ class OSIABWebViewActivity : AppCompatActivity() { pendingAcceptTypes = acceptTypes pendingCaptureEnabled = captureEnabled - // If camera is needed and not granted -> request permission + // if camera permission is not granted, request permission if (ContextCompat.checkSelfPermission( this@OSIABWebViewActivity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED ) { @@ -616,7 +605,7 @@ class OSIABWebViewActivity : AppCompatActivity() { arrayOf(Manifest.permission.CAMERA), CAMERA_PERMISSION_REQUEST_CODE ) - // Don’t launch chooser yet — wait for permission result + // don’t launch chooser yet, wait for permission result return true } @@ -673,9 +662,7 @@ class OSIABWebViewActivity : AppCompatActivity() { val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) } - grantUriPermissions(this@OSIABWebViewActivity, takePictureIntent, currentPhotoUri!!) intentList.add(takePictureIntent) } @@ -690,9 +677,7 @@ class OSIABWebViewActivity : AppCompatActivity() { val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, currentVideoUri) - addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION) } - grantUriPermissions(this@OSIABWebViewActivity, takeVideoIntent, currentVideoUri!!) intentList.add(takeVideoIntent) } From b04eb84af12523885d1362fbddf6791774d796ef Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Fri, 5 Sep 2025 11:52:56 -0300 Subject: [PATCH 09/22] refactor: multiple refactors to make the code more readable and cleaner References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 47 +++++++------------ 1 file changed, 18 insertions(+), 29 deletions(-) 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 deb4ce7..5b9ec57 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 @@ -8,7 +8,6 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Environment import android.provider.MediaStore import android.util.Log import android.view.Gravity @@ -90,31 +89,20 @@ class OSIABWebViewActivity : AppCompatActivity() { 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 - ) - filePathCallback = null - */ - val uris = when { result.resultCode != Activity.RESULT_OK -> null result.data?.data != null -> WebChromeClient.FileChooserParams.parseResult( result.resultCode, result.data - ) // gallery + ) // file was selected from gallery or file manager currentPhotoUri != null -> arrayOf(currentPhotoUri) // photo capture currentVideoUri != null -> arrayOf(currentVideoUri) // video capture else -> null } - filePathCallback?.onReceiveValue(uris) filePathCallback = null currentPhotoUri = null currentVideoUri = null - } // for back navigation @@ -133,7 +121,7 @@ class OSIABWebViewActivity : AppCompatActivity() { const val ENABLED_ALPHA = 1.0f const val REQUEST_STANDARD_PERMISSION = 622 const val REQUEST_LOCATION_PERMISSION = 623 - const val CAMERA_PERMISSION_REQUEST_CODE = 824 + const val REQUEST_CAMERA_PERMISSION = 624 const val LOG_TAG = "OSIABWebViewActivity" val errorsToHandle = listOf( WebViewClient.ERROR_HOST_LOOKUP, @@ -373,10 +361,10 @@ class OSIABWebViewActivity : AppCompatActivity() { geolocationCallback = null geolocationOrigin = null } - CAMERA_PERMISSION_REQUEST_CODE -> { + REQUEST_CAMERA_PERMISSION -> { val granted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } if (granted) { - // Permission granted, launch the file chooser + // permission granted, launch the file chooser try { filePathCallback?.let { (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() @@ -386,7 +374,7 @@ class OSIABWebViewActivity : AppCompatActivity() { (webView.webChromeClient as? OSIABWebChromeClient)?.cancelFileChooser() } } else { - // Permission denied, try file chooser without camera + // permission denied, launch the file chooser without camera options try { filePathCallback?.let { (webView.webChromeClient as? OSIABWebChromeClient)?.retryFileChooser() @@ -584,8 +572,8 @@ class OSIABWebViewActivity : AppCompatActivity() { // handle opening the file chooser within the WebView override fun onShowFileChooser( - webView: WebView?, - filePathCallback: ValueCallback>?, + webView: WebView, + filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams ): Boolean { @@ -603,7 +591,7 @@ class OSIABWebViewActivity : AppCompatActivity() { ActivityCompat.requestPermissions( this@OSIABWebViewActivity, arrayOf(Manifest.permission.CAMERA), - CAMERA_PERMISSION_REQUEST_CODE + REQUEST_CAMERA_PERMISSION ) // don’t launch chooser yet, wait for permission result return true @@ -651,7 +639,7 @@ class OSIABWebViewActivity : AppCompatActivity() { this@OSIABWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED if (permissionGranted) { - // --- Photo capture --- + // photo capture if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { val photoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg") currentPhotoUri = FileProvider.getUriForFile( @@ -666,7 +654,7 @@ class OSIABWebViewActivity : AppCompatActivity() { intentList.add(takePictureIntent) } - // --- Video capture --- + // video capture if (acceptTypes.contains("video") || acceptTypes.isEmpty()) { val videoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4") currentVideoUri = FileProvider.getUriForFile( @@ -683,24 +671,25 @@ class OSIABWebViewActivity : AppCompatActivity() { } + // only camera intents (no gallery) if (isCaptureEnabled && permissionGranted) { - // Only camera intents (no gallery) val chooser = if (intentList.size == 1) { - intentList[0] // single option launch directly + // with single option we launch the camera directly + intentList[0] } else { Intent(Intent.ACTION_CHOOSER).apply { + // with multiple options we show the chooser putExtra(Intent.EXTRA_INTENT, intentList[0]) - if (intentList.size > 1) { - putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.drop(1).toTypedArray()) - } + putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.drop(1).toTypedArray()) } } fileChooserLauncher.launch(chooser) } else if (!isCaptureEnabled) { // if capture is not enabled, we always show the full chooser // if capture is enabled, we only want to show camera options (photo and video) - // so if capture is enabled but permission is not granted, we don't do anything - // Normal flow with gallery/file picker + // so if capture is enabled but permission is not granted (else), we don't do anything + + // flow with gallery/file picker val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = when { From c74b4bbf9f2959ea889245dbb92359f0d11719f9 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Fri, 5 Sep 2025 13:52:06 -0300 Subject: [PATCH 10/22] chore: refactor comments References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../osinappbrowserlib/views/OSIABWebViewActivity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5b9ec57..2436f3c 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 @@ -95,8 +95,8 @@ class OSIABWebViewActivity : AppCompatActivity() { result.resultCode, result.data ) // file was selected from gallery or file manager - currentPhotoUri != null -> arrayOf(currentPhotoUri) // photo capture - currentVideoUri != null -> arrayOf(currentVideoUri) // video capture + currentPhotoUri != null -> arrayOf(currentPhotoUri) // photo capture, since URI is not in data + currentVideoUri != null -> arrayOf(currentVideoUri) // fallback for video capture, if video URI is not in data else -> null } filePathCallback?.onReceiveValue(uris) From 0bb0486a264bd70d1174516bf6264c7b3ffd1959 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Fri, 5 Sep 2025 17:22:57 -0300 Subject: [PATCH 11/22] fix: some devices (e.g. Samsung) don't return video in results.data, so we need to check if either photo or video was taken References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) 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 2436f3c..449ba69 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 @@ -82,7 +82,9 @@ class OSIABWebViewActivity : AppCompatActivity() { 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 @@ -94,14 +96,21 @@ class OSIABWebViewActivity : AppCompatActivity() { result.data?.data != null -> WebChromeClient.FileChooserParams.parseResult( result.resultCode, result.data - ) // file was selected from gallery or file manager - currentPhotoUri != null -> arrayOf(currentPhotoUri) // photo capture, since URI is not in data - currentVideoUri != null -> arrayOf(currentVideoUri) // fallback for video capture, if video URI is not in 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 } @@ -641,13 +650,14 @@ class OSIABWebViewActivity : AppCompatActivity() { if (permissionGranted) { // photo capture if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { - val photoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg") - currentPhotoUri = FileProvider.getUriForFile( - this@OSIABWebViewActivity, - "${this@OSIABWebViewActivity.packageName}.fileprovider", - photoFile - ) - + 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) } @@ -656,12 +666,14 @@ class OSIABWebViewActivity : AppCompatActivity() { // video capture if (acceptTypes.contains("video") || acceptTypes.isEmpty()) { - val videoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4") - currentVideoUri = FileProvider.getUriForFile( - this@OSIABWebViewActivity, - "${this@OSIABWebViewActivity.packageName}.fileprovider", - videoFile - ) + 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) From a590bb4db4cbc93e85bf4570860d13a3ebaf44bc Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Fri, 5 Sep 2025 17:26:36 -0300 Subject: [PATCH 12/22] chore: refactor comment placement --- .../osinappbrowserlib/views/OSIABWebViewActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 449ba69..9195e12 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 @@ -647,8 +647,8 @@ class OSIABWebViewActivity : AppCompatActivity() { val permissionGranted = ContextCompat.checkSelfPermission( this@OSIABWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + // photo capture if (permissionGranted) { - // photo capture if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { currentPhotoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg").also { file -> currentPhotoUri = FileProvider.getUriForFile( From ddbfa34d75917f81fba7aee9cd2ed75e9a167068 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Fri, 5 Sep 2025 18:03:23 -0300 Subject: [PATCH 13/22] chore: update changelog References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From aa8715fd7e93b7d59f5443a0605bac9948de11cd Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Fri, 5 Sep 2025 18:07:57 -0300 Subject: [PATCH 14/22] chore: remove extra lines --- .../osinappbrowserlib/views/OSIABWebViewActivity.kt | 2 -- 1 file changed, 2 deletions(-) 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 9195e12..71db59e 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 @@ -585,9 +585,7 @@ class OSIABWebViewActivity : AppCompatActivity() { filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams ): Boolean { - this@OSIABWebViewActivity.filePathCallback = filePathCallback - val acceptTypes = fileChooserParams.acceptTypes.joinToString() val captureEnabled = fileChooserParams.isCaptureEnabled pendingAcceptTypes = acceptTypes From c0b31945f2da5bbc82bcb569d04befc9f27f74a2 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Fri, 5 Sep 2025 18:17:28 -0300 Subject: [PATCH 15/22] chore: add comment explaining usage of FileProvider References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 8d8e88c..4def0b9 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -29,6 +29,7 @@ android:excludeFromRecents="true" android:windowSoftInputMode="stateAlwaysHidden|adjustPan" /> + Date: Fri, 5 Sep 2025 18:18:08 -0300 Subject: [PATCH 16/22] chore: update lib version to 1.6.0 References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From af05759f572fef1a08280c552d8896ca17ec117b Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Mon, 8 Sep 2025 08:42:55 -0300 Subject: [PATCH 17/22] refactor: remove unnecessary if/else References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) 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 71db59e..b8e2a24 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 @@ -371,27 +371,15 @@ class OSIABWebViewActivity : AppCompatActivity() { geolocationOrigin = null } REQUEST_CAMERA_PERMISSION -> { - val granted = grantResults.all { it == PackageManager.PERMISSION_GRANTED } - if (granted) { - // permission granted, launch the file chooser - 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() - } - } else { - // permission denied, launch the file chooser without camera options - 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() + // 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() } } } From c3251dc5943f52ec373ad35b3c8f3fb02fb591d0 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Mon, 8 Sep 2025 09:37:45 -0300 Subject: [PATCH 18/22] feat: only request camera permission if it is declared in AndroidManifest.xml References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) 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 b8e2a24..9b326df 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 @@ -579,10 +579,8 @@ class OSIABWebViewActivity : AppCompatActivity() { pendingAcceptTypes = acceptTypes pendingCaptureEnabled = captureEnabled - // if camera permission is not granted, request permission - if (ContextCompat.checkSelfPermission( - this@OSIABWebViewActivity, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED - ) { + // if camera permission is declared in manifest but is not granted, request it + if (hasCameraPermissionDeclared() && !isCameraPermissionGranted()) { ActivityCompat.requestPermissions( this@OSIABWebViewActivity, arrayOf(Manifest.permission.CAMERA), @@ -630,11 +628,11 @@ class OSIABWebViewActivity : AppCompatActivity() { private fun launchFileChooser(acceptTypes: String = "", isCaptureEnabled: Boolean = false) { val intentList = mutableListOf() - val permissionGranted = ContextCompat.checkSelfPermission( - this@OSIABWebViewActivity, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + val permissionNotDeclaredOrGranted = hasCameraPermissionDeclared().not() || isCameraPermissionGranted() // photo capture - if (permissionGranted) { + // if permission isn't declared, we don't need it + if (permissionNotDeclaredOrGranted) { if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { currentPhotoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg").also { file -> currentPhotoUri = FileProvider.getUriForFile( @@ -670,7 +668,7 @@ class OSIABWebViewActivity : AppCompatActivity() { } // only camera intents (no gallery) - if (isCaptureEnabled && permissionGranted) { + if (isCaptureEnabled && permissionNotDeclaredOrGranted) { val chooser = if (intentList.size == 1) { // with single option we launch the camera directly intentList[0] @@ -699,7 +697,7 @@ class OSIABWebViewActivity : AppCompatActivity() { val chooser = Intent(Intent.ACTION_CHOOSER).apply { putExtra(Intent.EXTRA_INTENT, contentIntent) - if (permissionGranted) { + if (permissionNotDeclaredOrGranted) { if (intentList.isNotEmpty()) { putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toTypedArray()) } @@ -707,13 +705,39 @@ class OSIABWebViewActivity : AppCompatActivity() { } fileChooserLauncher.launch(chooser) } else { - // capture enabled but permission not granted + // capture enabled but permission declared and not granted // as our only option is to capture, we can't do anything cancelFileChooser() return } } + 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 + } + } From 0a052303a32ce5c51743ffe86f5d4b97686d46e5 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Mon, 8 Sep 2025 09:55:00 -0300 Subject: [PATCH 19/22] refactor: use more human readable format for file names References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../osinappbrowserlib/views/OSIABWebViewActivity.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 9b326df..8bdc256 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 @@ -49,6 +49,9 @@ 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() { @@ -140,7 +143,9 @@ class OSIABWebViewActivity : AppCompatActivity() { private fun createTempFile(context: Context, prefix: String, suffix: String): File { val storageDir = context.cacheDir - return File.createTempFile("${prefix}${System.currentTimeMillis()}_", suffix, storageDir) + val formatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss", Locale.getDefault()) + val timeStamp = LocalDateTime.now().format(formatter) + return File.createTempFile("${prefix}${timeStamp}_", suffix, storageDir) } } From c1f6a4b93d2e44c3064422115507a32ab44cda7b Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Mon, 8 Sep 2025 10:04:40 -0300 Subject: [PATCH 20/22] refactor: no need to duplicate variables References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) 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 8bdc256..ad6570d 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 @@ -552,8 +552,8 @@ class OSIABWebViewActivity : AppCompatActivity() { private inner class OSIABWebChromeClient : WebChromeClient() { // for handling uploads (photo, video, gallery, files) - private var pendingAcceptTypes: String = "" - private var pendingCaptureEnabled: Boolean = false + private var acceptTypes: String = "" + private var captureEnabled: Boolean = false // handle standard permissions (e.g. audio, camera) override fun onPermissionRequest(request: PermissionRequest?) { @@ -579,10 +579,8 @@ class OSIABWebViewActivity : AppCompatActivity() { fileChooserParams: FileChooserParams ): Boolean { this@OSIABWebViewActivity.filePathCallback = filePathCallback - val acceptTypes = fileChooserParams.acceptTypes.joinToString() - val captureEnabled = fileChooserParams.isCaptureEnabled - pendingAcceptTypes = acceptTypes - pendingCaptureEnabled = captureEnabled + acceptTypes = fileChooserParams.acceptTypes.joinToString() + captureEnabled = fileChooserParams.isCaptureEnabled // if camera permission is declared in manifest but is not granted, request it if (hasCameraPermissionDeclared() && !isCameraPermissionGranted()) { @@ -616,19 +614,19 @@ class OSIABWebViewActivity : AppCompatActivity() { fun cancelFileChooser() { filePathCallback?.onReceiveValue(null) filePathCallback = null - pendingAcceptTypes = "" - pendingCaptureEnabled = false + acceptTypes = "" + captureEnabled = false } fun retryFileChooser() { try { - launchFileChooser(pendingAcceptTypes, pendingCaptureEnabled) + launchFileChooser(acceptTypes, captureEnabled) } catch (e: Exception) { e.printStackTrace() cancelFileChooser() } - pendingAcceptTypes = "" - pendingCaptureEnabled = false + acceptTypes = "" + captureEnabled = false } private fun launchFileChooser(acceptTypes: String = "", isCaptureEnabled: Boolean = false) { From aaa3d4183c7413d8778e146eb4bfcdeec4234a21 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Mon, 8 Sep 2025 10:40:59 -0300 Subject: [PATCH 21/22] refactor: remove unnecessary comments References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../osinappbrowserlib/views/OSIABWebViewActivity.kt | 2 -- 1 file changed, 2 deletions(-) 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 ad6570d..341aa70 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 @@ -685,8 +685,6 @@ class OSIABWebViewActivity : AppCompatActivity() { fileChooserLauncher.launch(chooser) } else if (!isCaptureEnabled) { // if capture is not enabled, we always show the full chooser - // if capture is enabled, we only want to show camera options (photo and video) - // so if capture is enabled but permission is not granted (else), we don't do anything // flow with gallery/file picker val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { From 6516ba564caf709226c6d10565a79485a8313954 Mon Sep 17 00:00:00 2001 From: Alexandre Jacinto Date: Mon, 8 Sep 2025 10:57:57 -0300 Subject: [PATCH 22/22] refactor: split launchFileChooser in multiple functions for readability References: https://outsystemsrd.atlassian.net/browse/RMET-4466 --- .../views/OSIABWebViewActivity.kt | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) 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 341aa70..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 @@ -630,11 +630,27 @@ class OSIABWebViewActivity : AppCompatActivity() { } 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() - // photo capture - // if permission isn't declared, we don't need it if (permissionNotDeclaredOrGranted) { if (acceptTypes.contains("image") || acceptTypes.isEmpty()) { currentPhotoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg").also { file -> @@ -644,14 +660,11 @@ class OSIABWebViewActivity : AppCompatActivity() { file ) } - val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri) } intentList.add(takePictureIntent) } - - // video capture if (acceptTypes.contains("video") || acceptTypes.isEmpty()) { currentVideoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4").also { file -> currentVideoFile = file @@ -661,56 +674,43 @@ class OSIABWebViewActivity : AppCompatActivity() { file ) } - val takeVideoIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply { putExtra(MediaStore.EXTRA_OUTPUT, currentVideoUri) } intentList.add(takeVideoIntent) } - } + return intentList + } - // only camera intents (no gallery) - if (isCaptureEnabled && permissionNotDeclaredOrGranted) { - val chooser = if (intentList.size == 1) { - // with single option we launch the camera directly - intentList[0] - } else { - Intent(Intent.ACTION_CHOOSER).apply { - // with multiple options we show the chooser - putExtra(Intent.EXTRA_INTENT, intentList[0]) - putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.drop(1).toTypedArray()) - } - } - fileChooserLauncher.launch(chooser) - } else if (!isCaptureEnabled) { - // if capture is not enabled, we always show the full chooser - - // flow with gallery/file picker - val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = when { - acceptTypes.contains("video") -> "video/*" - acceptTypes.contains("image") -> "image/*" - else -> "*/*" - } + 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) + } - val chooser = Intent(Intent.ACTION_CHOOSER).apply { - putExtra(Intent.EXTRA_INTENT, contentIntent) - if (permissionNotDeclaredOrGranted) { - if (intentList.isNotEmpty()) { - putExtra(Intent.EXTRA_INITIAL_INTENTS, intentList.toTypedArray()) - } - } + 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) - } else { - // capture enabled but permission declared and not granted - // as our only option is to capture, we can't do anything - cancelFileChooser() - return } + fileChooserLauncher.launch(chooser) } private fun isCameraPermissionGranted(): Boolean {