Commit 041ff7da authored by Tiger Oakes's avatar Tiger Oakes
Browse files

Closes #4636 - Add ShareTarget data class

parent 35c1f766
......@@ -60,7 +60,8 @@ data class WebAppManifest(
val scope: String? = null,
@ColorInt val themeColor: Int? = null,
val relatedApplications: List<ExternalApplicationResource> = emptyList(),
val preferRelatedApplications: Boolean = false
val preferRelatedApplications: Boolean = false,
val shareTarget: ShareTarget? = null
) {
/**
* Defines the developers’ preferred display mode for the website.
......@@ -191,4 +192,62 @@ data class WebAppManifest(
val value: String
)
}
/**
* Used to define how the web app receives share data.
* If present, a share target should be created so that other Android apps can share to this web app.
*
* @property action URL to open on share
* @property method Method to use with [action]. Either "GET" or "POST".
* @property encType MIME type to specify how the params are encoded.
* @property params Specifies what query parameters correspond to share data.
*/
data class ShareTarget(
val action: String,
val method: RequestMethod = RequestMethod.GET,
val encType: EncodingType = EncodingType.URL_ENCODED,
val params: Params = Params()
) {
/**
* Specifies what query parameters correspond to share data.
*
* @property title Name of the query parameter used for the title of the data being shared.
* @property text Name of the query parameter used for the body of the data being shared.
* @property url Name of the query parameter used for a URL referring to a shared resource.
* @property files Form fields used to share files.
*/
data class Params(
val title: String? = null,
val text: String? = null,
val url: String? = null,
val files: List<Files> = emptyList()
)
/**
* Specifies a form field member used to share files.
*
* @property name Name of the form field.
* @property accept Accepted MIME types or file extensions.
*/
data class Files(
val name: String,
val accept: List<String>
)
/**
* Valid HTTP methods for [ShareTarget.method].
*/
enum class RequestMethod {
GET, POST
}
/**
* Valid encoding MIME types for [ShareTarget.encType].
*/
enum class EncodingType(val type: String) {
URL_ENCODED("application/x-www-form-urlencoded"),
MULTIPART("multipart/form-data")
}
}
}
......@@ -8,6 +8,10 @@ package mozilla.components.concept.engine.manifest
import android.graphics.Color
import androidx.annotation.ColorInt
import mozilla.components.concept.engine.manifest.parser.ShareTargetParser
import mozilla.components.concept.engine.manifest.parser.parseIcons
import mozilla.components.concept.engine.manifest.parser.serializeEnumName
import mozilla.components.concept.engine.manifest.parser.serializeIcons
import mozilla.components.support.ktx.android.org.json.asSequence
import mozilla.components.support.ktx.android.org.json.tryGetString
import org.json.JSONArray
......@@ -61,7 +65,8 @@ class WebAppManifestParser {
lang = json.tryGetString("lang"),
orientation = parseOrientation(json),
relatedApplications = parseRelatedApplications(json),
preferRelatedApplications = json.optBoolean("prefer_related_applications", false)
preferRelatedApplications = json.optBoolean("prefer_related_applications", false),
shareTarget = ShareTargetParser.parse(json.optJSONObject("share_target"))
))
} catch (e: JSONException) {
Result.Failure(e)
......@@ -94,6 +99,7 @@ class WebAppManifestParser {
putOpt("orientation", serializeEnumName(manifest.orientation.name))
put("related_applications", serializeRelatedApplications(manifest.relatedApplications))
put("prefer_related_applications", manifest.preferRelatedApplications)
putOpt("share_target", ShareTargetParser.serialize(manifest.shareTarget))
}
}
......
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.concept.engine.manifest.parser
import mozilla.components.concept.engine.manifest.WebAppManifest.ShareTarget
import mozilla.components.support.ktx.android.org.json.asSequence
import mozilla.components.support.ktx.android.org.json.toJSONArray
import mozilla.components.support.ktx.android.org.json.tryGetString
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
internal object ShareTargetParser {
/**
* Parses a share target inside a web app manifest.
*/
fun parse(json: JSONObject?): ShareTarget? {
val action = json?.tryGetString("action") ?: return null
val method = parseMethod(json.tryGetString("method"))
val encType = parseEncType(json.tryGetString("enctype"))
val params = json.optJSONObject("params")
return if (method != null && encType != null && validMethodAndEncType(method, encType)) {
return ShareTarget(
action = action,
method = method,
encType = encType,
params = ShareTarget.Params(
title = params?.tryGetString("title"),
text = params?.tryGetString("text"),
url = params?.tryGetString("url"),
files = parseFiles(params)
)
)
} else {
null
}
}
/**
* Serializes a share target to JSON for a web app manifest.
*/
fun serialize(shareTarget: ShareTarget?): JSONObject? {
shareTarget ?: return null
return JSONObject().apply {
put("action", shareTarget.action)
put("method", shareTarget.method.name)
put("enctype", shareTarget.encType.type)
val params = JSONObject().apply {
put("title", shareTarget.params.title)
put("text", shareTarget.params.text)
put("url", shareTarget.params.url)
put("files", shareTarget.params.files.asSequence()
.map { file ->
JSONObject().apply {
put("name", file.name)
putOpt("accept", file.accept.toJSONArray())
}
}
.asIterable()
.toJSONArray())
}
put("params", params)
}
}
/**
* Convert string to [ShareTarget.RequestMethod]. Returns null if the string is invalid.
*/
private fun parseMethod(method: String?): ShareTarget.RequestMethod? {
method ?: return ShareTarget.RequestMethod.GET
return try {
ShareTarget.RequestMethod.valueOf(method.toUpperCase(Locale.ROOT))
} catch (e: IllegalArgumentException) {
null
}
}
/**
* Convert string to [ShareTarget.EncodingType]. Returns null if the string is invalid.
*/
private fun parseEncType(encType: String?): ShareTarget.EncodingType? {
val typeString = encType?.toLowerCase(Locale.ROOT) ?: return ShareTarget.EncodingType.URL_ENCODED
return ShareTarget.EncodingType.values().find { it.type == typeString }
}
/**
* Checks that [encType] is URL_ENCODED (if [method] is GET or POST) or MULTIPART (only if POST)
*/
private fun validMethodAndEncType(
method: ShareTarget.RequestMethod,
encType: ShareTarget.EncodingType
) = when (encType) {
ShareTarget.EncodingType.URL_ENCODED -> true
ShareTarget.EncodingType.MULTIPART -> method == ShareTarget.RequestMethod.POST
}
private fun parseFiles(params: JSONObject?) =
when (val files = params?.opt("files")) {
is JSONObject -> listOfNotNull(parseFile(files))
is JSONArray -> files.asSequence { i -> getJSONObject(i) }
.mapNotNull(::parseFile)
.toList()
else -> emptyList()
}
private fun parseFile(file: JSONObject): ShareTarget.Files? {
val name = file.tryGetString("name")
val accept = file.opt("accept")
if (name.isNullOrEmpty()) return null
return ShareTarget.Files(
name = name,
accept = when (accept) {
is String -> listOf(accept)
is JSONArray -> accept.asSequence { i -> getString(i) }.toList()
else -> emptyList()
}
)
}
}
......@@ -4,13 +4,16 @@
@file:Suppress("TooManyFunctions")
package mozilla.components.concept.engine.manifest
package mozilla.components.concept.engine.manifest.parser
import mozilla.components.concept.engine.manifest.Size
import mozilla.components.concept.engine.manifest.WebAppManifest
import mozilla.components.support.ktx.android.org.json.asSequence
import mozilla.components.support.ktx.android.org.json.tryGet
import mozilla.components.support.ktx.android.org.json.tryGetString
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
private val whitespace = "\\s+".toRegex()
......@@ -48,17 +51,19 @@ private fun parseStringSet(set: Any?): Sequence<String>? = when (set) {
}
private fun parseIconSizes(json: JSONObject): List<Size> {
val sizes = parseStringSet(json.tryGet("sizes")) ?: return emptyList()
val sizes = parseStringSet(json.tryGet("sizes"))
?: return emptyList()
return sizes.mapNotNull { Size.parse(it) }.toList()
}
private fun parsePurposes(json: JSONObject): Set<WebAppManifest.Icon.Purpose> {
val purpose = parseStringSet(json.tryGet("purpose")) ?: return setOf(WebAppManifest.Icon.Purpose.ANY)
val purpose = parseStringSet(json.tryGet("purpose"))
?: return setOf(WebAppManifest.Icon.Purpose.ANY)
return purpose
.mapNotNull {
when (it.toLowerCase()) {
when (it.toLowerCase(Locale.ROOT)) {
"badge" -> WebAppManifest.Icon.Purpose.BADGE
"maskable" -> WebAppManifest.Icon.Purpose.MASKABLE
"any" -> WebAppManifest.Icon.Purpose.ANY
......@@ -68,7 +73,7 @@ private fun parsePurposes(json: JSONObject): Set<WebAppManifest.Icon.Purpose> {
.toSet()
}
internal fun serializeEnumName(name: String) = name.toLowerCase().replace('_', '-')
internal fun serializeEnumName(name: String) = name.toLowerCase(Locale.ROOT).replace('_', '-')
internal fun serializeIcons(icons: List<WebAppManifest.Icon>): JSONArray {
val list = icons.map { icon ->
......
......@@ -299,6 +299,83 @@ class WebAppManifestParserTest {
}
}
@Test
fun `Parsing manifest from Squoosh`() {
val json = loadManifest("squoosh.json")
val result = WebAppManifestParser().parse(json)
assertTrue(result is WebAppManifestParser.Result.Success)
val manifest = (result as WebAppManifestParser.Result.Success).manifest
assertNotNull(manifest)
assertEquals("Squoosh", manifest.name)
assertEquals("Squoosh", manifest.shortName)
assertEquals("/", manifest.startUrl)
assertEquals(WebAppManifest.DisplayMode.STANDALONE, manifest.display)
assertEquals(Color.WHITE, manifest.backgroundColor)
assertEquals(WebAppManifest.TextDirection.AUTO, manifest.dir)
assertEquals(WebAppManifest.Orientation.ANY, manifest.orientation)
assertNull(manifest.scope)
assertEquals(rgb(247, 143, 33), manifest.themeColor)
assertEquals(1, manifest.icons.size)
manifest.icons[0].apply {
assertEquals("/assets/icon-large.png", src)
assertEquals("image/png", type)
assertEquals(listOf(Size(1024, 1024)), sizes)
assertEquals(setOf(WebAppManifest.Icon.Purpose.ANY), purpose)
}
manifest.shareTarget!!.apply {
assertEquals("/?share-target", action)
assertEquals(WebAppManifest.ShareTarget.RequestMethod.POST, method)
assertEquals(WebAppManifest.ShareTarget.EncodingType.MULTIPART, encType)
assertEquals(
WebAppManifest.ShareTarget.Params(
title = "title",
text = "body",
url = "uri",
files = listOf(
WebAppManifest.ShareTarget.Files(
name = "file",
accept = listOf("image/*")
)
)
),
params
)
}
}
@Test
fun `Parsing minimal manifest with share target`() {
val json = loadManifest("minimal_share_target.json")
val result = WebAppManifestParser().parse(json)
assertTrue(result is WebAppManifestParser.Result.Success)
val manifest = (result as WebAppManifestParser.Result.Success).manifest
assertNotNull(manifest)
assertEquals("Minimal", manifest.name)
assertEquals("/", manifest.startUrl)
manifest.shareTarget!!.apply {
assertEquals("/share-target", action)
assertEquals(WebAppManifest.ShareTarget.RequestMethod.GET, method)
assertEquals(WebAppManifest.ShareTarget.EncodingType.URL_ENCODED, encType)
assertEquals(
WebAppManifest.ShareTarget.Params(
files = listOf(
WebAppManifest.ShareTarget.Files(
name = "file",
accept = listOf("image/*")
)
)
),
params
)
}
}
@Test
fun `Parsing invalid JSON`() {
val json = loadManifest("invalid_json.json")
......@@ -323,6 +400,62 @@ class WebAppManifestParserTest {
assertTrue(result is WebAppManifestParser.Result.Failure)
}
@Test
fun `Ignore missing share target action`() {
val json = loadManifest("minimal.json").apply {
put("share_target", JSONObject().apply {
put("method", "POST")
})
}
val result = WebAppManifestParser().parse(json)
assertTrue(result is WebAppManifestParser.Result.Success)
assertNull(result.getOrNull()!!.shareTarget)
}
@Test
fun `Ignore invalid share target method`() {
val json = loadManifest("minimal.json").apply {
put("share_target", JSONObject().apply {
put("action", "https://mozilla.com/target")
put("method", "PATCH")
})
}
val result = WebAppManifestParser().parse(json)
assertTrue(result is WebAppManifestParser.Result.Success)
assertNull(result.getOrNull()!!.shareTarget)
}
@Test
fun `Ignore invalid share target encoding type`() {
val json = loadManifest("minimal.json").apply {
put("share_target", JSONObject().apply {
put("action", "https://mozilla.com/target")
put("enctype", "text/plain")
})
}
val result = WebAppManifestParser().parse(json)
assertTrue(result is WebAppManifestParser.Result.Success)
assertNull(result.getOrNull()!!.shareTarget)
}
@Test
fun `Ignore invalid share target method and encoding type combo`() {
val json = loadManifest("minimal.json").apply {
put("share_target", JSONObject().apply {
put("action", "https://mozilla.com/target")
put("method", "GET")
put("enctype", "multipart/form-data")
})
}
val result = WebAppManifestParser().parse(json)
assertTrue(result is WebAppManifestParser.Result.Success)
assertNull(result.getOrNull()!!.shareTarget)
}
@Test
fun `Parsing manifest with unusual values`() {
val json = loadManifest("unusual.json")
......
{
"name": "Minimal",
"start_url": "/",
"share_target": {
"action": "/share-target",
"params": {
"files": {
"name": "file",
"accept": "image/*"
}
}
}
}
{
"name": "Squoosh",
"short_name": "Squoosh",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#ffffff",
"theme_color": "#f78f21",
"icons": [
{
"src": "/assets/icon-large.png",
"type": "image/png",
"sizes": "1024x1024"
}
],
"share_target": {
"action": "/?share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "body",
"url": "uri",
"files": [
{
"name": "file",
"accept": ["image/*"]
}
]
}
}
}
......@@ -19,5 +19,17 @@
"scope": "/",
"display": "minimal-ui",
"dir": "rtl",
"orientation": "portrait"
"orientation": "portrait",
"share_target": {
"action": "/",
"method": "get",
"params": {
"title": "title",
"url": "uri"
},
"files": {
"name": "file",
"accept": "image/*"
}
}
}
......@@ -15,6 +15,9 @@ permalink: /changelog/
* **feature-contextmenu**
* The "Save Image" context menu item will no longer prompt before downloading the image.
* **concept-engine**
* Added `WebAppManifest.ShareTarget` data class.
# 16.0.0
* [Commits](https://github.com/mozilla-mobile/android-components/compare/v15.0.0...v16.0.0)
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment