WebAppManifestParser.kt

/* 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

import androidx.annotation.ColorInt
import androidx.core.graphics.toColorInt
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
import org.json.JSONException
import org.json.JSONObject

/**
 * Parser for constructing a [WebAppManifest] from JSON.
 */
class WebAppManifestParser {
    /**
     * A parsing result.
     */
    sealed class Result {
        /**
         * The JSON was parsed successful.
         *
         * @property manifest The parsed [WebAppManifest] object.
         */
        data class Success(val manifest: WebAppManifest) : Result()

        /**
         * Parsing the JSON failed.
         *
         * @property exception The exception that was thrown while parsing the manifest.
         */
        data class Failure(val exception: JSONException) : Result()
    }

    /**
     * Parses the provided JSON and returns a [WebAppManifest] (wrapped in [Result.Success] if parsing was successful.
     * Otherwise [Result.Failure].
     *
     * Gecko performs some initial parsing on the Web App Manifest, so the [JSONObject] we work with
     * does not match what was originally provided by the website. Gecko:
     * - Changes relative URLs to be absolute
     * - Changes some space-separated strings into arrays (purpose, sizes)
     * - Changes colors to follow Android format (#AARRGGBB)
     * - Removes invalid enum values (ie display: halfscreen)
     * - Ensures display, dir, start_url, and scope always have a value
     * - Trims most strings (name, short_name, ...)
     * See https://searchfox.org/mozilla-central/source/dom/manifest/ManifestProcessor.jsm
     */
    fun parse(json: JSONObject): Result {
        return try {
            val shortName = json.tryGetString("short_name")
            val name = json.tryGetString("name") ?: shortName
                ?: return Result.Failure(JSONException("Missing manifest name"))

            Result.Success(
                WebAppManifest(
                    name = name,
                    shortName = shortName,
                    startUrl = json.getString("start_url"),
                    display = parseDisplayMode(json),
                    backgroundColor = parseColor(json.tryGetString("background_color")),
                    description = json.tryGetString("description"),
                    icons = parseIcons(json),
                    scope = json.tryGetString("scope"),
                    themeColor = parseColor(json.tryGetString("theme_color")),
                    dir = parseTextDirection(json),
                    lang = json.tryGetString("lang"),
                    orientation = parseOrientation(json),
                    relatedApplications = parseRelatedApplications(json),
                    preferRelatedApplications = json.optBoolean("prefer_related_applications", false),
                    shareTarget = ShareTargetParser.parse(json.optJSONObject("share_target")),
                ),
            )
        } catch (e: JSONException) {
            Result.Failure(e)
        }
    }

    /**
     * Parses the provided JSON and returns a [WebAppManifest] (wrapped in [Result.Success] if parsing was successful.
     * Otherwise [Result.Failure].
     */
    fun parse(json: String) = try {
        parse(JSONObject(json))
    } catch (e: JSONException) {
        Result.Failure(e)
    }

    fun serialize(manifest: WebAppManifest) = JSONObject().apply {
        put("name", manifest.name)
        putOpt("short_name", manifest.shortName)
        put("start_url", manifest.startUrl)
        putOpt("display", serializeEnumName(manifest.display.name))
        putOpt("background_color", serializeColor(manifest.backgroundColor))
        putOpt("description", manifest.description)
        putOpt("icons", serializeIcons(manifest.icons))
        putOpt("scope", manifest.scope)
        putOpt("theme_color", serializeColor(manifest.themeColor))
        putOpt("dir", serializeEnumName(manifest.dir.name))
        putOpt("lang", manifest.lang)
        putOpt("orientation", serializeEnumName(manifest.orientation.name))
        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))
    }
}

/**
 * Returns the encapsulated value if this instance represents success or `null` if it is failure.
 */
fun WebAppManifestParser.Result.getOrNull(): WebAppManifest? = when (this) {
    is WebAppManifestParser.Result.Success -> manifest
    is WebAppManifestParser.Result.Failure -> null
}

private fun parseDisplayMode(json: JSONObject): WebAppManifest.DisplayMode {
    return when (json.optString("display")) {
        "standalone" -> WebAppManifest.DisplayMode.STANDALONE
        "fullscreen" -> WebAppManifest.DisplayMode.FULLSCREEN
        "minimal-ui" -> WebAppManifest.DisplayMode.MINIMAL_UI
        "browser" -> WebAppManifest.DisplayMode.BROWSER
        else -> WebAppManifest.DisplayMode.BROWSER
    }
}

@ColorInt
private fun parseColor(color: String?): Int? {
    if (color == null || !color.startsWith("#")) {
        return null
    }

    return try {
        color.toColorInt()
    } catch (e: IllegalArgumentException) {
        null
    }
}

private fun parseTextDirection(json: JSONObject): WebAppManifest.TextDirection {
    return when (json.optString("dir")) {
        "ltr" -> WebAppManifest.TextDirection.LTR
        "rtl" -> WebAppManifest.TextDirection.RTL
        "auto" -> WebAppManifest.TextDirection.AUTO
        else -> WebAppManifest.TextDirection.AUTO
    }
}

private fun parseOrientation(json: JSONObject) = when (json.optString("orientation")) {
    "any" -> WebAppManifest.Orientation.ANY
    "natural" -> WebAppManifest.Orientation.NATURAL
    "landscape" -> WebAppManifest.Orientation.LANDSCAPE
    "portrait" -> WebAppManifest.Orientation.PORTRAIT
    "portrait-primary" -> WebAppManifest.Orientation.PORTRAIT_PRIMARY
    "portrait-secondary" -> WebAppManifest.Orientation.PORTRAIT_SECONDARY
    "landscape-primary" -> WebAppManifest.Orientation.LANDSCAPE_PRIMARY
    "landscape-secondary" -> WebAppManifest.Orientation.LANDSCAPE_SECONDARY
    else -> WebAppManifest.Orientation.ANY
}

private fun parseRelatedApplications(json: JSONObject): List<WebAppManifest.ExternalApplicationResource> {
    val array = json.optJSONArray("related_applications") ?: return emptyList()

    return array
        .asSequence { i -> getJSONObject(i) }
        .mapNotNull { app -> parseRelatedApplication(app) }
        .toList()
}

private fun parseRelatedApplication(app: JSONObject): WebAppManifest.ExternalApplicationResource? {
    val platform = app.tryGetString("platform")
    val url = app.tryGetString("url")
    val id = app.tryGetString("id")
    return if (platform != null && (url != null || id != null)) {
        WebAppManifest.ExternalApplicationResource(
            platform = platform,
            url = url,
            id = id,
            minVersion = app.tryGetString("min_version"),
            fingerprints = parseFingerprints(app),
        )
    } else {
        null
    }
}

private fun parseFingerprints(app: JSONObject): List<WebAppManifest.ExternalApplicationResource.Fingerprint> {
    val array = app.optJSONArray("fingerprints") ?: return emptyList()

    return array
        .asSequence { i -> getJSONObject(i) }
        .map {
            WebAppManifest.ExternalApplicationResource.Fingerprint(
                type = it.getString("type"),
                value = it.getString("value"),
            )
        }
        .toList()
}

@Suppress("MagicNumber")
private fun serializeColor(color: Int?): String? = color?.let {
    String.format("#%06X", 0xFFFFFF and it)
}

private fun serializeRelatedApplications(
    relatedApplications: List<WebAppManifest.ExternalApplicationResource>,
): JSONArray {
    val list = relatedApplications.map { app ->
        JSONObject().apply {
            put("platform", app.platform)
            putOpt("url", app.url)
            putOpt("id", app.id)
            putOpt("min_version", app.minVersion)
            put("fingerprints", serializeFingerprints(app.fingerprints))
        }
    }
    return JSONArray(list)
}

private fun serializeFingerprints(
    fingerprints: List<WebAppManifest.ExternalApplicationResource.Fingerprint>,
): JSONArray {
    val list = fingerprints.map {
        JSONObject().apply {
            put("type", it.type)
            put("value", it.value)
        }
    }
    return JSONArray(list)
}