NavDeepLink.kt

/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package androidx.navigation

import android.net.Uri
import android.os.Bundle
import androidx.annotation.RestrictTo
import java.util.regex.Matcher
import java.util.regex.Pattern

/**
 * NavDeepLink encapsulates the parsing and matching of a navigation deep link.
 *
 * This should be added to a [NavDestination] using
 * [NavDestination.addDeepLink].
 */
public class NavDeepLink internal constructor(
    /**
     * The uri pattern from the NavDeepLink.
     *
     * @see NavDeepLinkRequest.uri
     */
    public val uriPattern: String?,
    /**
     * The action from the NavDeepLink.
     *
     * @see NavDeepLinkRequest.action
     */
    public val action: String?,
    /**
     * The mimeType from the NavDeepLink.
     *
     * @see NavDeepLinkRequest.mimeType
     */
    public val mimeType: String?
) {
    private val arguments = mutableListOf<String>()
    private val paramArgMap = mutableMapOf<String, ParamQuery>()
    private var patternFinalRegex: String? = null
    private val pattern by lazy {
        patternFinalRegex?.let { Pattern.compile(it, Pattern.CASE_INSENSITIVE) }
    }
    private var isParameterizedQuery = false

    private var mimeTypeFinalRegex: String? = null
    private val mimeTypePattern by lazy {
        mimeTypeFinalRegex?.let { Pattern.compile(it) }
    }

    /** Arguments present in the deep link, including both path and query arguments. */
    internal val argumentsNames: List<String>
        get() = arguments + paramArgMap.values.flatMap { it.arguments }

    public var isExactDeepLink: Boolean = false
        /** @suppress */
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        get
        internal set

    /** @suppress */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public constructor(uri: String) : this(uri, null, null)

    private fun buildPathRegex(
        uri: String,
        uriRegex: StringBuilder,
        fillInPattern: Pattern
    ): Boolean {
        val matcher = fillInPattern.matcher(uri)
        var appendPos = 0
        // Track whether this is an exact deep link
        var exactDeepLink = !uri.contains(".*")
        while (matcher.find()) {
            val argName = matcher.group(1) as String
            arguments.add(argName)
            // Use Pattern.quote() to treat the input string as a literal
            uriRegex.append(Pattern.quote(uri.substring(appendPos, matcher.start())))
            uriRegex.append("([^/]+?)")
            appendPos = matcher.end()
            exactDeepLink = false
        }
        if (appendPos < uri.length) {
            // Use Pattern.quote() to treat the input string as a literal
            uriRegex.append(Pattern.quote(uri.substring(appendPos)))
        }
        // Match either the end of string if all params are optional or match the
        // question mark (or pound symbol) and 0 or more characters after it
        // We do not use '.*' here because the finalregex would replace it with a quoted
        // version below.
        uriRegex.append("($|(\?(.)*)|(\#(.)*))")
        return exactDeepLink
    }

    internal fun matches(uri: Uri): Boolean {
        return matches(NavDeepLinkRequest(uri, null, null))
    }

    internal fun matches(deepLinkRequest: NavDeepLinkRequest): Boolean {
        if (!matchUri(deepLinkRequest.uri)) {
            return false
        }
        return if (!matchAction(deepLinkRequest.action)) {
            false
        } else matchMimeType(deepLinkRequest.mimeType)
    }

    private fun matchUri(uri: Uri?): Boolean {
        // If the null status of both are not the same return false.
        return if (uri == null == (pattern != null)) {
            false
        } else uri == null || pattern!!.matcher(uri.toString()).matches()
        // If both are null return true, otherwise see if they match
    }

    private fun matchAction(action: String?): Boolean {
        // If the null status of both are not the same return false.
        return if (action == null == (this.action != null)) {
            false
        } else action == null || this.action == action
        // If both are null return true, otherwise see if they match
    }

    private fun matchMimeType(mimeType: String?): Boolean {
        // If the null status of both are not the same return false.
        return if (mimeType == null == (this.mimeType != null)) {
            false
        } else mimeType == null || mimeTypePattern!!.matcher(mimeType).matches()

        // If both are null return true, otherwise see if they match
    }

    /** @suppress */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun getMimeTypeMatchRating(mimeType: String): Int {
        return if (this.mimeType == null || !mimeTypePattern!!.matcher(mimeType).matches()) {
            -1
        } else MimeType(this.mimeType)
            .compareTo(MimeType(mimeType))
    }

    @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS", "NullableCollection")
    /** Pattern.compile has no nullability for the regex parameter
     * @suppress
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun getMatchingArguments(
        deepLink: Uri,
        arguments: Map<String, NavArgument?>
    ): Bundle? {
        val matcher = pattern?.matcher(deepLink.toString()) ?: return null
        if (!matcher.matches()) {
            return null
        }
        val bundle = Bundle()
        val size = this.arguments.size
        for (index in 0 until size) {
            val argumentName = this.arguments[index]
            val value = Uri.decode(matcher.group(index + 1))
            val argument = arguments[argumentName]
            try {
                if (parseArgument(bundle, argumentName, value, argument)) {
                    return null
                }
            } catch (e: IllegalArgumentException) {
                // Failed to parse means this isn't a valid deep link
                // for the given URI - i.e., the URI contains a non-integer
                // value for an integer argument
                return null
            }
        }
        if (isParameterizedQuery) {
            for (paramName in paramArgMap.keys) {
                var argMatcher: Matcher? = null
                val storedParam = paramArgMap[paramName]
                val inputParams = deepLink.getQueryParameter(paramName)
                if (inputParams != null) {
                    // Match the input arguments with the saved regex
                    argMatcher = Pattern.compile(
                        storedParam!!.paramRegex, Pattern.DOTALL
                    ).matcher(inputParams)
                    if (!argMatcher.matches()) {
                        return null
                    }
                }
                val queryParamBundle = Bundle()
                try {
                    // Params could have multiple arguments, we need to handle them all
                    for (index in 0 until storedParam!!.size()) {
                        var value: String? = null
                        if (argMatcher != null) {
                            value = argMatcher.group(index + 1) ?: ""
                        }
                        val argName = storedParam.getArgumentName(index)
                        val argument = arguments[argName]
                        // Passing in a value the exact same as the placeholder will be treated the
                        // as if no value was passed, being replaced if it is optional or throwing an
                        // error if it is required.
                        if (value != null && value != "{$argName}" &&
                            parseArgument(queryParamBundle, argName, value, argument)
                        ) {
                            return null
                        }
                    }
                    bundle.putAll(queryParamBundle)
                } catch (e: IllegalArgumentException) {
                    // Failed to parse means that at least one of the arguments that were supposed
                    // to fill in the query parameter was not valid and therefore, we will exclude
                    // that particular parameter from the argument bundle.
                }
            }
        }

        // Check that all required arguments are present in bundle
        for ((argName, argument) in arguments.entries) {
            val argumentIsRequired = argument != null && !argument.isNullable &&
                !argument.isDefaultValuePresent
            if (argumentIsRequired && !bundle.containsKey(argName)) return null
        }

        return bundle
    }

    private fun parseArgument(
        bundle: Bundle,
        name: String,
        value: String,
        argument: NavArgument?
    ): Boolean {
        if (argument != null) {
            val type = argument.type
            type.parseAndPut(bundle, name, value)
        } else {
            bundle.putString(name, value)
        }
        return false
    }

    /**
     * Used to maintain query parameters and the mArguments they match with.
     */
    private class ParamQuery {
        var paramRegex: String? = null
        val arguments = mutableListOf<String>()

        fun addArgumentName(name: String) {
            arguments.add(name)
        }

        fun getArgumentName(index: Int): String {
            return arguments[index]
        }

        fun size(): Int {
            return arguments.size
        }
    }

    private class MimeType(mimeType: String) : Comparable<MimeType> {
        var type: String
        var subType: String
        override fun compareTo(other: MimeType): Int {
            var result = 0
            // matching just subtypes is 1
            // matching just types is 2
            // matching both is 3
            if (type == other.type) {
                result += 2
            }
            if (subType == other.subType) {
                result++
            }
            return result
        }

        init {
            val typeAndSubType =
                mimeType.split("/".toRegex()).dropLastWhile { it.isEmpty() }
            type = typeAndSubType[0]
            subType = typeAndSubType[1]
        }
    }

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is NavDeepLink) return false
        return uriPattern == other.uriPattern &&
            action == other.action &&
            mimeType == other.mimeType
    }

    override fun hashCode(): Int {
        var result = 0
        result = 31 * result + uriPattern.hashCode()
        result = 31 * result + action.hashCode()
        result = 31 * result + mimeType.hashCode()
        return result
    }

    /**
     * A builder for constructing [NavDeepLink] instances.
     */
    public class Builder {

        /** @suppress */
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        public constructor()

        private var uriPattern: String? = null
        private var action: String? = null
        private var mimeType: String? = null

        /**
         * Set the uri pattern for the [NavDeepLink].
         *
         * @param uriPattern The uri pattern to add to the NavDeepLink
         *
         * @return This builder.
         */
        public fun setUriPattern(uriPattern: String): Builder {
            this.uriPattern = uriPattern
            return this
        }

        /**
         * Set the action for the [NavDeepLink].
         *
         * @throws IllegalArgumentException if the action is empty.
         *
         * @param action the intent action for the NavDeepLink
         *
         * @return This builder.
         */
        public fun setAction(action: String): Builder {
            // if the action given at runtime is empty we should throw
            require(action.isNotEmpty()) { "The NavDeepLink cannot have an empty action." }
            this.action = action
            return this
        }

        /**
         * Set the mimeType for the [NavDeepLink].
         *
         * @param mimeType the mimeType for the NavDeepLink
         *
         * @return This builder.
         */
        public fun setMimeType(mimeType: String): Builder {
            this.mimeType = mimeType
            return this
        }

        /**
         * Build the [NavDeepLink] specified by this builder.
         *
         * @return the newly constructed NavDeepLink.
         */
        public fun build(): NavDeepLink {
            return NavDeepLink(uriPattern, action, mimeType)
        }

        internal companion object {
            /**
             * Creates a [NavDeepLink.Builder] with a set uri pattern.
             *
             * @param uriPattern The uri pattern to add to the NavDeepLink
             * @return a [Builder] instance
             */
            @JvmStatic
            fun fromUriPattern(uriPattern: String): Builder {
                val builder = Builder()
                builder.setUriPattern(uriPattern)
                return builder
            }

            /**
             * Creates a [NavDeepLink.Builder] with a set action.
             *
             * @throws IllegalArgumentException if the action is empty.
             *
             * @param action the intent action for the NavDeepLink
             * @return a [Builder] instance
             */
            @JvmStatic
            fun fromAction(action: String): Builder {
                // if the action given at runtime is empty we should throw
                require(action.isNotEmpty()) { "The NavDeepLink cannot have an empty action." }
                val builder = Builder()
                builder.setAction(action)
                return builder
            }

            /**
             * Creates a [NavDeepLink.Builder] with a set mimeType.
             *
             * @param mimeType the mimeType for the NavDeepLink
             * @return a [Builder] instance
             */
            @JvmStatic
            fun fromMimeType(mimeType: String): Builder {
                val builder = Builder()
                builder.setMimeType(mimeType)
                return builder
            }
        }
    }

    private companion object {
        private val SCHEME_PATTERN = Pattern.compile("^[a-zA-Z]+[+\w\-.]*:")
    }

    init {
        if (uriPattern != null) {
            val parameterizedUri = Uri.parse(uriPattern)
            isParameterizedQuery = parameterizedUri.query != null
            val uriRegex = StringBuilder("^")
            if (!SCHEME_PATTERN.matcher(uriPattern).find()) {
                uriRegex.append("http[s]?://")
            }
            @Suppress("RegExpRedundantEscape")
            val fillInPattern = Pattern.compile("\{(.+?)\}")
            if (isParameterizedQuery) {
                var matcher = Pattern.compile("(\?)").matcher(uriPattern)
                if (matcher.find()) {
                    isExactDeepLink = buildPathRegex(
                        uriPattern.substring(0, matcher.start()),
                        uriRegex,
                        fillInPattern
                    )
                }
                for (paramName in parameterizedUri.queryParameterNames) {
                    val argRegex = StringBuilder()
                    val queryParam = parameterizedUri.getQueryParameter(paramName) as String
                    matcher = fillInPattern.matcher(queryParam)
                    var appendPos = 0
                    val param = ParamQuery()
                    // Build the regex for each query param
                    while (matcher.find()) {
                        param.addArgumentName(matcher.group(1) as String)
                        argRegex.append(
                            Pattern.quote(
                                queryParam.substring(
                                    appendPos,
                                    matcher.start()
                                )
                            )
                        )
                        argRegex.append("(.+?)?")
                        appendPos = matcher.end()
                    }
                    if (appendPos < queryParam.length) {
                        argRegex.append(Pattern.quote(queryParam.substring(appendPos)))
                    }
                    // Save the regex with wildcards unquoted, and add the param to the map with its
                    // name as the key
                    param.paramRegex = argRegex.toString().replace(".*", "\E.*\Q")
                    paramArgMap[paramName] = param
                }
            } else {
                isExactDeepLink = buildPathRegex(uriPattern, uriRegex, fillInPattern)
            }
            // Since we've used Pattern.quote() above, we need to
            // specifically escape any .* instances to ensure
            // they are still treated as wildcards in our final regex
            patternFinalRegex = uriRegex.toString().replace(".*", "\E.*\Q")
        }
        if (mimeType != null) {
            val mimeTypePattern = Pattern.compile("^[\s\S]+/[\s\S]+$")
            val mimeTypeMatcher = mimeTypePattern.matcher(mimeType)
            require(mimeTypeMatcher.matches()) {
                "The given mimeType $mimeType does not match to required \"type/subtype\" format"
            }

            // get the type and subtype of the mimeType
            val splitMimeType = MimeType(
                mimeType
            )

            // the matching pattern can have the exact name or it can be wildcard literal (*)
            val mimeTypeRegex = "^(${splitMimeType.type}|[*]+)/(${splitMimeType.subType}|[*]+)$"

            // if the deep link type or subtype is wildcard, allow anything
            mimeTypeFinalRegex = mimeTypeRegex.replace("*|[*]", "[\s\S]")
        }
    }
}