diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6284d6d..b40c980 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).
+## [2.0.3]
+
+### Fixes
+
+- Preserve all `accept` MIME types and extensions from the WebView file chooser instead of collapsing them into a single guessed category, so inputs like `accept="image/*,application/pdf,application/msword"` correctly offer every accepted type in the picker instead of silently dropping non-image/video types. Also fixes a bug where combining `` with a documents-only `accept` list would silently cancel the file chooser instead of opening it ([RMET-5438](https://outsystemsrd.atlassian.net/browse/RMET-5438) + [RPM-7140](https://outsystemsrd.atlassian.net/browse/RPM-7140)).
+
## [2.0.2]
### Fixes
diff --git a/pom.xml b/pom.xml
index 259c045..41e4cf8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,5 +6,5 @@
4.0.0io.ionic.libsioninappbrowser-android
- 2.0.2
+ 2.0.3
diff --git a/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFileChooserHelper.kt b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFileChooserHelper.kt
new file mode 100644
index 0000000..b9310d5
--- /dev/null
+++ b/src/main/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFileChooserHelper.kt
@@ -0,0 +1,131 @@
+package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers
+
+import androidx.annotation.VisibleForTesting
+import android.webkit.MimeTypeMap
+
+/**
+ * Resolves the `accept` tokens reported by a WebView's file chooser
+ * (`WebChromeClient.FileChooserParams#getAcceptTypes`) into concrete MIME types, and
+ * decides how those MIME types should be applied to an `ACTION_GET_CONTENT` intent.
+ *
+ * The `accept` attribute of an HTML `` can list a mix of:
+ * - MIME types, e.g. `image/png`, `application/pdf`, or an image wildcard type
+ * - file extensions, e.g. `.pdf`, `.docx`
+ *
+ * Android's `ACTION_GET_CONTENT` intent only understands MIME types (via `type` and
+ * [android.content.Intent.EXTRA_MIME_TYPES]), so extension tokens must be resolved to a
+ * MIME type first. When the accept list spans more than one disjoint MIME category
+ * (e.g. images and PDFs), a single `type` string can't represent all of them, so the
+ * intent must use the wildcard type together with `EXTRA_MIME_TYPES`.
+ */
+object OSIABFileChooserHelper {
+
+ /**
+ * MIME type used on an intent to indicate no filtering, i.e. any content is acceptable.
+ */
+ const val WILDCARD_MIME_TYPE = "*/*"
+
+ /**
+ * The `type` / [android.content.Intent.EXTRA_MIME_TYPES] pair to apply to an
+ * `ACTION_GET_CONTENT` intent.
+ *
+ * @property type the MIME type to set as the intent's `type`.
+ * @property extraMimeTypes the value to set as [android.content.Intent.EXTRA_MIME_TYPES],
+ * or `null` when no additional filtering beyond [type] is needed.
+ */
+ data class ChooserMimeConfig(
+ val type: String,
+ val extraMimeTypes: List?
+ )
+
+ /**
+ * Resolves raw accept tokens (MIME types and/or file extensions) into a deduplicated
+ * list of concrete MIME types, using the device's [MimeTypeMap] to resolve extensions.
+ * Tokens that cannot be resolved (e.g. unknown extensions) are dropped rather than
+ * causing a failure.
+ *
+ * @param acceptTypes raw tokens as provided by `FileChooserParams#getAcceptTypes()`.
+ * @return the deduplicated list of resolved MIME types, in the same order they were
+ * first encountered. Empty if no token could be resolved.
+ */
+ fun resolveMimeTypes(acceptTypes: List): List =
+ resolveMimeTypes(acceptTypes, ::defaultExtensionToMimeType)
+
+ /**
+ * Overload of [resolveMimeTypes] that takes the extension-to-MIME-type lookup as a
+ * parameter instead of always using [defaultExtensionToMimeType], so this logic can be
+ * unit tested without an Android runtime (`MimeTypeMap.getSingleton()` is not available
+ * in a plain JVM test). Production code should use the single-argument [resolveMimeTypes]
+ * overload; this one exists for tests only.
+ *
+ * @param acceptTypes raw tokens as provided by `FileChooserParams#getAcceptTypes()`.
+ * @param extensionToMimeType resolves a file extension (without the leading dot) to a
+ * MIME type, or `null` if it can't be resolved.
+ * @return the deduplicated list of resolved MIME types, in the same order they were
+ * first encountered. Empty if no token could be resolved.
+ */
+ @VisibleForTesting
+ internal fun resolveMimeTypes(
+ acceptTypes: List,
+ extensionToMimeType: (String) -> String?
+ ): List =
+ acceptTypes.mapNotNull { resolveToken(it, extensionToMimeType) }.distinct()
+
+ /**
+ * Decides the `type` / [android.content.Intent.EXTRA_MIME_TYPES] pair to use for an
+ * `ACTION_GET_CONTENT` intent, given an already-resolved list of MIME types (see
+ * [resolveMimeTypes]).
+ *
+ * - No resolvable MIME types: falls back to [WILDCARD_MIME_TYPE], no extra MIME types.
+ * - Exactly one resolved MIME type: uses that type directly (whether it's already a
+ * wildcard like an image wildcard type, or a specific type like `application/pdf`),
+ * no extra MIME types needed.
+ * - More than one resolved MIME type: uses [WILDCARD_MIME_TYPE] plus `EXTRA_MIME_TYPES`
+ * with the full list, regardless of whether they share a top-level category. Multiple
+ * distinct subtypes under the same category (e.g. `application/pdf` + `application/msword`)
+ * are intentionally not collapsed into that category's wildcard, since a top-level
+ * category wildcard can be far broader than what was actually requested (the
+ * `application` category wildcard alone also matches zip, octet-stream, JSON, etc).
+ *
+ * @param resolvedMimeTypes MIME types as returned by [resolveMimeTypes]. Deduplicated
+ * internally, so callers do not need to pre-deduplicate.
+ * @return the [ChooserMimeConfig] to apply to the `ACTION_GET_CONTENT` intent.
+ */
+ fun resolveChooserMimeConfig(resolvedMimeTypes: List): ChooserMimeConfig {
+ val distinct = resolvedMimeTypes.distinct()
+ return when (distinct.size) {
+ 0 -> ChooserMimeConfig(WILDCARD_MIME_TYPE, null)
+ 1 -> ChooserMimeConfig(distinct[0], null)
+ else -> ChooserMimeConfig(WILDCARD_MIME_TYPE, distinct)
+ }
+ }
+
+ /**
+ * Resolves a single raw accept token into a MIME type.
+ *
+ * @param token a single accept token, either a MIME type (e.g. `"image/png"`) or a file
+ * extension (e.g. `".pdf"`).
+ * @param extensionToMimeType resolves a file extension (without the leading dot) to a
+ * MIME type, or `null` if it can't be resolved.
+ * @return the resolved MIME type, or `null` if [token] is blank, is neither a MIME type
+ * nor an extension, or is an extension that [extensionToMimeType] can't resolve.
+ */
+ private fun resolveToken(token: String, extensionToMimeType: (String) -> String?): String? {
+ val trimmed = token.trim()
+ return when {
+ trimmed.isEmpty() -> null
+ trimmed.contains("/") -> trimmed
+ trimmed.startsWith(".") -> extensionToMimeType(trimmed.removePrefix(".").lowercase())
+ else -> null
+ }
+ }
+
+ /**
+ * Default extension-to-MIME-type lookup, backed by the device's [MimeTypeMap].
+ *
+ * @param extension a file extension without the leading dot, e.g. `"pdf"`.
+ * @return the resolved MIME type, or `null` if [MimeTypeMap] has no mapping for it.
+ */
+ private fun defaultExtensionToMimeType(extension: String): String? =
+ MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
+}
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 6665832..72a250e 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
@@ -43,6 +43,7 @@ import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.OSIABEvents
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.R
+import com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers.OSIABFileChooserHelper
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers.OSIABPdfHelper
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABToolbarPosition
import com.outsystems.plugins.inappbrowser.osinappbrowserlib.models.OSIABWebViewOptions
@@ -619,7 +620,7 @@ open class OSIABWebViewActivity : AppCompatActivity() {
private inner class OSIABWebChromeClient : WebChromeClient() {
// for handling uploads (photo, video, gallery, files)
- private var acceptTypes: String = ""
+ private var acceptTypes: List = emptyList()
private var captureEnabled: Boolean = false
// handle standard permissions (e.g. audio, camera)
@@ -646,7 +647,7 @@ open class OSIABWebViewActivity : AppCompatActivity() {
fileChooserParams: FileChooserParams
): Boolean {
this@OSIABWebViewActivity.filePathCallback = filePathCallback
- acceptTypes = fileChooserParams.acceptTypes.joinToString()
+ acceptTypes = fileChooserParams.acceptTypes.toList()
captureEnabled = fileChooserParams.isCaptureEnabled
// if camera permission is declared in manifest but is not granted, request it
@@ -681,7 +682,7 @@ open class OSIABWebViewActivity : AppCompatActivity() {
fun cancelFileChooser() {
filePathCallback?.onReceiveValue(null)
filePathCallback = null
- acceptTypes = ""
+ acceptTypes = emptyList()
captureEnabled = false
}
@@ -692,17 +693,17 @@ open class OSIABWebViewActivity : AppCompatActivity() {
e.printStackTrace()
cancelFileChooser()
}
- acceptTypes = ""
+ acceptTypes = emptyList()
captureEnabled = false
}
- private fun launchFileChooser(acceptTypes: String = "", isCaptureEnabled: Boolean = false) {
+ private fun launchFileChooser(acceptTypes: List = emptyList(), 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)
+ launchCameraChooser(intentList, acceptTypes, permissionNotDeclaredOrGranted)
} else if (!isCaptureEnabled) {
// if capture is not enabled, we show the full chooser
launchFullChooser(intentList, acceptTypes, permissionNotDeclaredOrGranted)
@@ -714,12 +715,16 @@ open class OSIABWebViewActivity : AppCompatActivity() {
}
}
- private fun buildPhotoVideoIntents(acceptTypes: String): MutableList {
+ private fun buildPhotoVideoIntents(acceptTypes: List): MutableList {
val intentList = mutableListOf()
val permissionNotDeclaredOrGranted = hasCameraPermissionDeclared().not() || isCameraPermissionGranted()
if (permissionNotDeclaredOrGranted) {
- if (acceptTypes.contains("image") || acceptTypes.isEmpty()) {
+ val resolvedMimeTypes = OSIABFileChooserHelper.resolveMimeTypes(acceptTypes)
+ val noAcceptSpecified = acceptTypes.none { it.isNotBlank() } ||
+ resolvedMimeTypes.contains(OSIABFileChooserHelper.WILDCARD_MIME_TYPE)
+
+ if (noAcceptSpecified || resolvedMimeTypes.any { it.startsWith("image/") }) {
currentPhotoFile = createTempFile(this@OSIABWebViewActivity, "IMG_", ".jpg").also { file ->
currentPhotoUri = FileProvider.getUriForFile(
this@OSIABWebViewActivity,
@@ -733,7 +738,7 @@ open class OSIABWebViewActivity : AppCompatActivity() {
}
intentList.add(takePictureIntent)
}
- if (acceptTypes.contains("video") || acceptTypes.isEmpty()) {
+ if (noAcceptSpecified || resolvedMimeTypes.any { it.startsWith("video/") }) {
currentVideoFile = createTempFile(this@OSIABWebViewActivity, "VID_", ".mp4").also { file ->
currentVideoFile = file
currentVideoUri = FileProvider.getUriForFile(
@@ -752,7 +757,18 @@ open class OSIABWebViewActivity : AppCompatActivity() {
return intentList
}
- private fun launchCameraChooser(intentList: List) {
+ private fun launchCameraChooser(
+ intentList: List,
+ acceptTypes: List,
+ permissionNotDeclaredOrGranted: Boolean
+ ) {
+ if (intentList.isEmpty()) {
+ // nothing capturable for this accept list (e.g. a documents-only accept
+ // combined with a capture hint) - fall back to the full chooser instead
+ // of indexing into an empty list
+ launchFullChooser(intentList, acceptTypes, permissionNotDeclaredOrGranted)
+ return
+ }
val chooser = if (intentList.size == 1) {
intentList[0]
} else {
@@ -764,14 +780,13 @@ open class OSIABWebViewActivity : AppCompatActivity() {
fileChooserLauncher.launch(chooser)
}
- private fun launchFullChooser(intentList: List, acceptTypes: String, permissionNotDeclaredOrGranted: Boolean) {
+ private fun launchFullChooser(intentList: List, acceptTypes: List, permissionNotDeclaredOrGranted: Boolean) {
+ val resolvedMimeTypes = OSIABFileChooserHelper.resolveMimeTypes(acceptTypes)
+ val mimeConfig = OSIABFileChooserHelper.resolveChooserMimeConfig(resolvedMimeTypes)
val contentIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
- type = when {
- acceptTypes.contains("video") -> "video/*"
- acceptTypes.contains("image") -> "image/*"
- else -> "*/*"
- }
+ type = mimeConfig.type
+ mimeConfig.extraMimeTypes?.let { putExtra(Intent.EXTRA_MIME_TYPES, it.toTypedArray()) }
}
val chooser = Intent(Intent.ACTION_CHOOSER).apply {
putExtra(Intent.EXTRA_INTENT, contentIntent)
diff --git a/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFileChooserHelperTest.kt b/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFileChooserHelperTest.kt
new file mode 100644
index 0000000..67a4bc6
--- /dev/null
+++ b/src/test/java/com.outsystems.plugins.inappbrowser/osinappbrowserlib/helpers/OSIABFileChooserHelperTest.kt
@@ -0,0 +1,169 @@
+package com.outsystems.plugins.inappbrowser.osinappbrowserlib.helpers
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class OSIABFileChooserHelperTest {
+
+ private val fakeExtensionLookup: (String) -> String? = { extension ->
+ when (extension) {
+ "pdf" -> "application/pdf"
+ "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ else -> null
+ }
+ }
+
+ // region resolveMimeTypes
+
+ @Test
+ fun `resolveMimeTypes keeps already-MIME tokens as-is`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(listOf("image/*", "application/pdf"))
+ assertEquals(listOf("image/*", "application/pdf"), result)
+ }
+
+ @Test
+ fun `resolveMimeTypes resolves pdf extension token to its MIME type`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(listOf(".pdf"), fakeExtensionLookup)
+ assertEquals(listOf("application/pdf"), result)
+ }
+
+ @Test
+ fun `resolveMimeTypes resolves docx extension token to its MIME type`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(listOf(".docx"), fakeExtensionLookup)
+ assertEquals(
+ listOf("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
+ result
+ )
+ }
+
+ @Test
+ fun `resolveMimeTypes resolves xlsx extension token to its MIME type`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(listOf(".xlsx"), fakeExtensionLookup)
+ assertEquals(
+ listOf("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
+ result
+ )
+ }
+
+ @Test
+ fun `resolveMimeTypes resolves mixed MIME and extension tokens`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(
+ listOf("image/*", ".pdf", ".docx"),
+ fakeExtensionLookup
+ )
+ assertEquals(
+ listOf(
+ "image/*",
+ "application/pdf",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ ),
+ result
+ )
+ }
+
+ @Test
+ fun `resolveMimeTypes deduplicates repeated tokens`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(
+ listOf("image/*", "image/*", ".pdf", ".pdf"),
+ fakeExtensionLookup
+ )
+ assertEquals(listOf("image/*", "application/pdf"), result)
+ }
+
+ @Test
+ fun `resolveMimeTypes drops unresolvable extension tokens without crashing`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(
+ listOf(".unknownext", "image/*"),
+ fakeExtensionLookup
+ )
+ assertEquals(listOf("image/*"), result)
+ }
+
+ @Test
+ fun `resolveMimeTypes ignores blank tokens`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(listOf("", " ", "image/*"), fakeExtensionLookup)
+ assertEquals(listOf("image/*"), result)
+ }
+
+ @Test
+ fun `resolveMimeTypes returns empty list when accept list is empty`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(emptyList(), fakeExtensionLookup)
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `resolveMimeTypes drops bare tokens with no slash and no leading dot`() {
+ val result = OSIABFileChooserHelper.resolveMimeTypes(listOf("pdf", "image/*"), fakeExtensionLookup)
+ assertEquals(listOf("image/*"), result)
+ }
+
+ @Test
+ fun `resolveMimeTypes deduplicates when a MIME token and an extension token resolve to the same type`() {
+ val lookup: (String) -> String? = { if (it == "pdf") "application/pdf" else null }
+ val result = OSIABFileChooserHelper.resolveMimeTypes(listOf("application/pdf", ".pdf"), lookup)
+ assertEquals(listOf("application/pdf"), result)
+ }
+
+ // endregion
+
+ // region resolveChooserMimeConfig
+
+ @Test
+ fun `resolveChooserMimeConfig returns wildcard with no extras when list is empty`() {
+ val config = OSIABFileChooserHelper.resolveChooserMimeConfig(emptyList())
+ assertEquals(OSIABFileChooserHelper.WILDCARD_MIME_TYPE, config.type)
+ assertNull(config.extraMimeTypes)
+ }
+
+ @Test
+ fun `resolveChooserMimeConfig returns the single MIME type with no extras`() {
+ val config = OSIABFileChooserHelper.resolveChooserMimeConfig(listOf("image/*"))
+ assertEquals("image/*", config.type)
+ assertNull(config.extraMimeTypes)
+ }
+
+ @Test
+ fun `resolveChooserMimeConfig does not collapse same-category MIME types into that category's wildcard`() {
+ // e.g. application/pdf + application/msword must not become application/*, since
+ // that top-level wildcard is far broader than requested (also matches zip, etc.)
+ val config = OSIABFileChooserHelper.resolveChooserMimeConfig(listOf("image/png", "image/jpeg"))
+ assertEquals(OSIABFileChooserHelper.WILDCARD_MIME_TYPE, config.type)
+ assertEquals(setOf("image/png", "image/jpeg"), config.extraMimeTypes?.toSet())
+ }
+
+ @Test
+ fun `resolveChooserMimeConfig uses EXTRA_MIME_TYPES for multiple document subtypes under the application category`() {
+ val config = OSIABFileChooserHelper.resolveChooserMimeConfig(
+ listOf("application/pdf", "application/msword", "application/vnd.ms-excel")
+ )
+ assertEquals(OSIABFileChooserHelper.WILDCARD_MIME_TYPE, config.type)
+ assertEquals(
+ setOf("application/pdf", "application/msword", "application/vnd.ms-excel"),
+ config.extraMimeTypes?.toSet()
+ )
+ }
+
+ @Test
+ fun `resolveChooserMimeConfig uses wildcard type with extra MIME types for disjoint categories`() {
+ val config = OSIABFileChooserHelper.resolveChooserMimeConfig(
+ listOf("image/*", "application/pdf", "application/msword")
+ )
+ assertEquals(OSIABFileChooserHelper.WILDCARD_MIME_TYPE, config.type)
+ assertEquals(
+ setOf("image/*", "application/pdf", "application/msword"),
+ config.extraMimeTypes?.toSet()
+ )
+ }
+
+ @Test
+ fun `resolveChooserMimeConfig dedupes even when called with a non-distinct list`() {
+ val config = OSIABFileChooserHelper.resolveChooserMimeConfig(listOf("image/*", "image/*"))
+ assertEquals("image/*", config.type)
+ assertNull(config.extraMimeTypes)
+ }
+
+ // endregion
+}