blob: 0a06c70f3de2eaac3f539f00c75017ab9313f649 [file] [log] [blame]
/*
* 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 androidx.navigation.serialization.generateRoutePattern
import java.util.regex.Matcher
import java.util.regex.Pattern
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer
/**
* 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?
) {
// path
private val pathArgs = mutableListOf<String>()
private var pathRegex: String? = null
private val pathPattern by lazy {
pathRegex?.let { Pattern.compile(it, Pattern.CASE_INSENSITIVE) }
}
// query
private val isParameterizedQuery by lazy {
uriPattern != null && Uri.parse(uriPattern).query != null
}
private val queryArgsMap by lazy(LazyThreadSafetyMode.NONE) { parseQuery() }
private var isSingleQueryParamValueOnly = false
// fragment
private val fragArgsAndRegex: Pair<MutableList<String>, String>? by
lazy(LazyThreadSafetyMode.NONE) { parseFragment() }
private val fragArgs by lazy(LazyThreadSafetyMode.NONE) {
fragArgsAndRegex?.first ?: mutableListOf()
}
private val fragRegex by lazy(LazyThreadSafetyMode.NONE) {
fragArgsAndRegex?.second
}
private val fragPattern by lazy {
fragRegex?.let { Pattern.compile(it, Pattern.CASE_INSENSITIVE) }
}
// mime
private var mimeTypeRegex: String? = null
private val mimeTypePattern by lazy {
mimeTypeRegex?.let { Pattern.compile(it) }
}
/** Arguments present in the deep link, including both path and query arguments. */
internal val argumentsNames: List<String>
get() = pathArgs + queryArgsMap.values.flatMap { it.arguments } + fragArgs
public var isExactDeepLink: Boolean = false
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
get
internal set
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public constructor(uri: String) : this(uri, null, null)
private fun buildRegex(
uri: String,
args: MutableList<String>,
uriRegex: StringBuilder,
) {
val matcher = FILL_IN_PATTERN.matcher(uri)
var appendPos = 0
while (matcher.find()) {
val argName = matcher.group(1) as String
args.add(argName)
// Use Pattern.quote() to treat the input string as a literal
if (matcher.start() > appendPos) {
uriRegex.append(Pattern.quote(uri.substring(appendPos, matcher.start())))
}
uriRegex.append("([^/]+?)")
appendPos = matcher.end()
}
if (appendPos < uri.length) {
// Use Pattern.quote() to treat the input string as a literal
uriRegex.append(Pattern.quote(uri.substring(appendPos)))
}
}
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 == (pathPattern != null)) {
false
} else uri == null || pathPattern!!.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
}
@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
*
* May return null if any of the following:
* 1. missing required arguments that don't have default values
* 2. wrong value type (i.e. null for non-nullable arg)
* 3. other exceptions from parsing an argument value
*
* May return empty bundle if any of the following:
* 1. deeplink has no arguments
* 2. deeplink contains arguments with unknown default values (i.e. deeplink from safe args
* with unknown default values)
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public fun getMatchingArguments(
deepLink: Uri,
arguments: Map<String, NavArgument?>
): Bundle? {
// first check overall uri pattern for quick return if general pattern does not match
val matcher = pathPattern?.matcher(deepLink.toString()) ?: return null
if (!matcher.matches()) {
return null
}
// get matching path and query arguments and store in bundle
val bundle = Bundle()
if (!getMatchingPathArguments(matcher, bundle, arguments)) return null
if (isParameterizedQuery && !getMatchingQueryArguments(deepLink, bundle, arguments)) {
return null
}
// no match on optional fragment should not prevent a link from matching otherwise
getMatchingUriFragment(deepLink.fragment, bundle, arguments)
// Check that all required arguments are present in bundle
val missingRequiredArguments = arguments.missingRequiredArguments { argName ->
!bundle.containsKey(argName)
}
if (missingRequiredArguments.isNotEmpty()) return null
return bundle
}
/**
* Returns a bundle containing matching path and query arguments with the requested uri.
* It returns empty bundle if this Deeplink's path pattern does not match with the uri.
*/
internal fun getMatchingPathAndQueryArgs(
deepLink: Uri?,
arguments: Map<String, NavArgument?>
): Bundle {
val bundle = Bundle()
if (deepLink == null) return bundle
val matcher = pathPattern?.matcher(deepLink.toString()) ?: return bundle
if (!matcher.matches()) {
return bundle
}
getMatchingPathArguments(matcher, bundle, arguments)
if (isParameterizedQuery) getMatchingQueryArguments(deepLink, bundle, arguments)
return bundle
}
private fun getMatchingUriFragment(
fragment: String?,
bundle: Bundle,
arguments: Map<String, NavArgument?>
) {
// Base condition of a matching fragment is a complete match on regex pattern. If a
// required fragment arg is present while regex does not match, this will be caught later
// on as a non-match when we check for presence of required args in the bundle.
val matcher = fragPattern?.matcher(fragment.toString()) ?: return
if (!matcher.matches()) return
this.fragArgs.mapIndexed { index, argumentName ->
val value = Uri.decode(matcher.group(index + 1))
val argument = arguments[argumentName]
try {
parseArgument(bundle, argumentName, value, argument)
} catch (e: IllegalArgumentException) {
// parse failed, quick return
return
}
}
}
private fun getMatchingPathArguments(
matcher: Matcher,
bundle: Bundle,
arguments: Map<String, NavArgument?>
): Boolean {
this.pathArgs.mapIndexed { index, argumentName ->
val value = Uri.decode(matcher.group(index + 1))
val argument = arguments[argumentName]
try {
parseArgument(bundle, argumentName, value, argument)
} 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 false
}
}
// parse success
return true
}
private fun getMatchingQueryArguments(
deepLink: Uri,
bundle: Bundle,
arguments: Map<String, NavArgument?>
): Boolean {
queryArgsMap.forEach { entry ->
val paramName = entry.key
val storedParam = entry.value
var inputParams = deepLink.getQueryParameters(paramName)
if (isSingleQueryParamValueOnly) {
// If the deep link contains a single query param with no value,
// we will treat everything after the '?' as the input parameter
val argValue = deepLink.query
if (argValue != null && argValue != deepLink.toString()) {
inputParams = listOf(argValue)
}
}
if (!parseInputParams(inputParams, storedParam, bundle, arguments)) {
// failed to parse input parameters
return false
}
}
// parse success
return true
}
private fun parseInputParams(
inputParams: List<String>?,
storedParam: ParamQuery,
bundle: Bundle,
arguments: Map<String, NavArgument?>,
): Boolean {
inputParams?.forEach { inputParam ->
val argMatcher = storedParam.paramRegex?.let {
Pattern.compile(
it, Pattern.DOTALL
).matcher(inputParam)
}
if (argMatcher == null || !argMatcher.matches()) {
return false
}
val queryParamBundle = Bundle()
try {
storedParam.arguments.mapIndexed { index, argName ->
val value = argMatcher.group(index + 1) ?: ""
val argument = arguments[argName]
if (parseArgumentForRepeatedParam(bundle, argName, value, argument)) {
// Passing in a value the exact same as the placeholder will be treated the
// as if no value was passed (unless value is based on String),
// being replaced if it is optional or throwing an error if it is required.
parseArgument(queryParamBundle, argName, value, argument)
}
}
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.
}
}
// parse success
return true
}
internal fun calculateMatchingPathSegments(requestedLink: Uri?): Int {
if (requestedLink == null || uriPattern == null) return 0
val requestedPathSegments = requestedLink.pathSegments
val uriPathSegments = Uri.parse(uriPattern).pathSegments
val matches = requestedPathSegments.intersect(uriPathSegments)
return matches.size
}
/**
* Parses [value] based on the NavArgument's NavType and stores the result
* inside the [bundle]. Throws if parse fails.
*/
private fun parseArgument(
bundle: Bundle,
name: String,
value: String,
argument: NavArgument?
) {
if (argument != null) {
val type = argument.type
type.parseAndPut(bundle, name, value)
} else {
bundle.putString(name, value)
}
}
private fun parseArgumentForRepeatedParam(
bundle: Bundle,
name: String,
value: String?,
argument: NavArgument?
): Boolean {
if (!bundle.containsKey(name)) {
return true
}
if (argument != null) {
val type = argument.type
val previousValue = type[bundle, name]
type.parseAndPut(bundle, name, value, previousValue)
}
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 {
@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 uri pattern for the [NavDeepLink].
*
* Arguments extracted from destination [T] will be automatically appended to the base path
* provided in [basePath].
*
* Arguments are appended based on property name and in the same order as their declaration
* order in [T]. They are appended as query parameters if the argument has either:
*
* 1. a default value
* 2. a [NavType] of [CollectionNavType]
*
* Otherwise, the argument will be appended as path parameters. The final uriPattern
* is generated by concatenating `uriPattern + path parameters + query parameters`.
*
*
* For example, the `name` property in this class does not meet either conditions and will
* be appended as a path param.
* ```
* @Serializable
* class MyClass(val name: String)
* ```
* Given a uriPattern of "www.example.com", the generated final uriPattern
* will be `www.example.com/{name}`.
*
*
* The `name` property in this class has a default value and will be appended as a query.
* ```
* @Serializable
* class MyClass(val name: String = "default")
* ```
* Given a uriPattern of "www.example.com", the final generated uriPattern
* will be `www.example.com?name={name}`
*
*
* The append order is based on their declaration order in [T]
* ```
* @Serializable
* class MyClass(val name: String = "default", val id: Int, val code: Int)
* ```
* Given a uriPattern of "www.example.com", the final generated uriPattern
* will be `www.example.com/{id}/{code}?name={name}`. In this example, `name` is appended
* first as a query param, then `id` and `code` respectively as path params. The final
* pattern is then concatenated with `uriPattern + path + query`.
*
*
* @param T The destination's route from KClass
* @param basePath The base uri path to append arguments onto
* @param typeMap map of destination arguments' kotlin type [KType] to its respective custom
* [NavType]. May be empty if [T] does not use custom NavTypes.
*
* @return This builder.
*/
public inline fun <reified T : Any> setUriPattern(
basePath: String,
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
): Builder = setUriPattern(basePath, T::class, typeMap)
@OptIn(InternalSerializationApi::class)
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) // need to be public for reified delegation
public fun <T : Any> setUriPattern(
basePath: String,
route: KClass<T>,
typeMap: Map<KType, NavType<*>> = emptyMap(),
): Builder {
this.uriPattern = route.serializer().generateRoutePattern(typeMap, basePath)
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 uri pattern.
*
* Arguments extracted from destination [T] will be automatically appended to the
* base path provided in [basePath]
*
* @param T The destination's route from KClass
* @param basePath The base uri path to append arguments onto
* @param typeMap map of destination arguments' kotlin type [KType] to its
* respective custom [NavType]. May be empty if [T] does not use custom NavTypes.
* @return a [Builder] instance
*/
@JvmStatic
inline fun <reified T : Any> fromUriPattern(
basePath: String,
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
): Builder {
val builder = Builder()
builder.setUriPattern(basePath, T::class, typeMap)
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\\-.]*:")
private val FILL_IN_PATTERN = Pattern.compile("\\{(.+?)\\}")
}
private fun parsePath() {
if (uriPattern == null) return
val uriRegex = StringBuilder("^")
// append scheme pattern
if (!SCHEME_PATTERN.matcher(uriPattern).find()) {
uriRegex.append("http[s]?://")
}
// extract beginning of uriPattern until it hits either a query(?), a framgment(#), or
// end of uriPattern
var matcher = Pattern.compile("(\\?|\\#|$)").matcher(uriPattern)
matcher.find().let {
buildRegex(uriPattern.substring(0, matcher.start()), pathArgs, uriRegex)
isExactDeepLink = !uriRegex.contains(".*") && !uriRegex.contains("([^/]+?)")
// 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
uriRegex.append("($|(\\?(.)*)|(\\#(.)*))")
}
// we need to specifically escape any .* instances to ensure
// they are still treated as wildcards in our final regex
pathRegex = uriRegex.toString().replace(".*", "\\E.*\\Q")
}
private fun parseQuery(): MutableMap<String, ParamQuery> {
val paramArgMap = mutableMapOf<String, ParamQuery>()
if (!isParameterizedQuery) return paramArgMap
val uri = Uri.parse(uriPattern)
for (paramName in uri.queryParameterNames) {
val argRegex = StringBuilder()
val queryParams = uri.getQueryParameters(paramName)
require(queryParams.size <= 1) {
"Query parameter $paramName must only be present once in $uriPattern. " +
"To support repeated query parameters, use an array type for your " +
"argument and the pattern provided in your URI will be used to " +
"parse each query parameter instance."
}
val queryParam = queryParams.firstOrNull()
?: paramName.apply { isSingleQueryParamValueOnly = true }
val matcher = FILL_IN_PATTERN.matcher(queryParam)
var appendPos = 0
val param = ParamQuery()
// Build the regex for each query param
while (matcher.find()) {
// matcher.group(1) as String = "tab" (the extracted param arg from {tab})
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
}
return paramArgMap
}
private fun parseFragment(): Pair<MutableList<String>, String>? {
if (uriPattern == null || Uri.parse(uriPattern).fragment == null) return null
val fragArgs = mutableListOf<String>()
val fragment = Uri.parse(uriPattern).fragment
val fragRegex = StringBuilder()
buildRegex(fragment!!, fragArgs, fragRegex)
return fragArgs to fragRegex.toString()
}
private fun parseMime() {
if (mimeType == null) return
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 regex = "^(${splitMimeType.type}|[*]+)/(${splitMimeType.subType}|[*]+)$"
// if the deep link type or subtype is wildcard, allow anything
mimeTypeRegex = regex.replace("*|[*]", "[\\s\\S]")
}
init {
parsePath()
parseMime()
}
}