Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [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 `<input capture>` 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
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.ionic.libs</groupId>
<artifactId>ioninappbrowser-android</artifactId>
<version>2.0.2</version>
<version>2.0.3</version>
</project>
Original file line number Diff line number Diff line change
@@ -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 `<input type="file">` 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<String>?
)

/**
* 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<String>): List<String> =
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<String>,
extensionToMimeType: (String) -> String?
): List<String> =
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<String>): 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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String> = emptyList()
private var captureEnabled: Boolean = false

// handle standard permissions (e.g. audio, camera)
Expand All @@ -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
Expand Down Expand Up @@ -681,7 +682,7 @@ open class OSIABWebViewActivity : AppCompatActivity() {
fun cancelFileChooser() {
filePathCallback?.onReceiveValue(null)
filePathCallback = null
acceptTypes = ""
acceptTypes = emptyList()
captureEnabled = false
}

Expand All @@ -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<String> = 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)
Expand All @@ -714,12 +715,16 @@ open class OSIABWebViewActivity : AppCompatActivity() {
}
}

private fun buildPhotoVideoIntents(acceptTypes: String): MutableList<Intent> {
private fun buildPhotoVideoIntents(acceptTypes: List<String>): MutableList<Intent> {
val intentList = mutableListOf<Intent>()
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,
Expand All @@ -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(
Expand All @@ -752,7 +757,18 @@ open class OSIABWebViewActivity : AppCompatActivity() {
return intentList
}

private fun launchCameraChooser(intentList: List<Intent>) {
private fun launchCameraChooser(
intentList: List<Intent>,
acceptTypes: List<String>,
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 {
Expand All @@ -764,14 +780,13 @@ open class OSIABWebViewActivity : AppCompatActivity() {
fileChooserLauncher.launch(chooser)
}

private fun launchFullChooser(intentList: List<Intent>, acceptTypes: String, permissionNotDeclaredOrGranted: Boolean) {
private fun launchFullChooser(intentList: List<Intent>, acceptTypes: List<String>, 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)
Expand Down
Loading
Loading