NavDestination.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.content.Context
import android.content.res.Resources
import android.net.Uri
import android.os.Bundle
import android.util.AttributeSet
import androidx.annotation.CallSuper
import androidx.annotation.IdRes
import androidx.annotation.RestrictTo
import androidx.collection.SparseArrayCompat
import androidx.collection.valueIterator
import androidx.core.content.res.use
import androidx.navigation.common.R
import java.util.regex.Pattern
import kotlin.reflect.KClass

/**
 * NavDestination represents one node within an overall navigation graph.
 *
 * Each destination is associated with a [Navigator] which knows how to navigate to this
 * particular destination.
 *
 * Destinations declare a set of [actions][putAction] that they
 * support. These actions form a navigation API for the destination; the same actions declared
 * on different destinations that fill similar roles allow application code to navigate based
 * on semantic intent.
 *
 * Each destination has a set of [arguments][arguments] that will
 * be applied when [navigating][NavController.navigate] to that destination.
 * Any default values for those arguments can be overridden at the time of navigation.
 *
 * NavDestinations should be created via [Navigator.createDestination].
 */
public open class NavDestination(
    /**
     * The name associated with this destination's [Navigator].
     */
    public val navigatorName: String
) {
    /**
     * This optional annotation allows tooling to offer auto-complete for the
     * `android:name` attribute. This should match the class type passed to
     * [parseClassFromName] when parsing the
     * `android:name` attribute.
     */
    @kotlin.annotation.Retention(AnnotationRetention.BINARY)
    @Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS)
    public annotation class ClassType(val value: KClass<*>)

    /** @suppress */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public class DeepLinkMatch(
        public val destination: NavDestination,
        @get:Suppress("NullableCollection") // Needed for nullable bundle
        public val matchingArgs: Bundle?,
        private val isExactDeepLink: Boolean,
        private val matchingPathSegments: Int,
        private val hasMatchingAction: Boolean,
        private val mimeTypeMatchLevel: Int
    ) : Comparable<DeepLinkMatch> {
        override fun compareTo(other: DeepLinkMatch): Int {
            // Prefer exact deep links
            if (isExactDeepLink && !other.isExactDeepLink) {
                return 1
            } else if (!isExactDeepLink && other.isExactDeepLink) {
                return -1
            }
            // Then prefer most exact match path segments
            val pathSegmentDifference = matchingPathSegments - other.matchingPathSegments
            if (pathSegmentDifference > 0) {
                return 1
            } else if (pathSegmentDifference < 0) {
                return -1
            }
            if (matchingArgs != null && other.matchingArgs == null) {
                return 1
            } else if (matchingArgs == null && other.matchingArgs != null) {
                return -1
            }
            if (matchingArgs != null) {
                val sizeDifference = matchingArgs.size() - other.matchingArgs!!.size()
                if (sizeDifference > 0) {
                    return 1
                } else if (sizeDifference < 0) {
                    return -1
                }
            }
            if (hasMatchingAction && !other.hasMatchingAction) {
                return 1
            } else if (!hasMatchingAction && other.hasMatchingAction) {
                return -1
            }
            return mimeTypeMatchLevel - other.mimeTypeMatchLevel
        }
    }

    /**
     * Gets the [NavGraph] that contains this destination. This will be set when a
     * destination is added to a NavGraph via [NavGraph.addDestination].
     */
    public var parent: NavGraph? = null
        /** @suppress */
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        public set
    private var idName: String? = null

    /**
     * The descriptive label of this destination.
     */
    public var label: CharSequence? = null
    private val deepLinks = mutableListOf<NavDeepLink>()
    private val actions: SparseArrayCompat<NavAction> = SparseArrayCompat()

    private var _arguments: MutableMap<String, NavArgument> = mutableMapOf()

    /**
     * The arguments supported by this destination. Returns a read-only map of argument names
     * to [NavArgument] objects that can be used to check the type, default value
     * and nullability of the argument.
     *
     * To add and remove arguments for this NavDestination
     * use [addArgument] and [removeArgument].
     * @return Read-only map of argument names to arguments.
     */
    public val arguments: Map<String, NavArgument>
        get() = _arguments.toMap()

    /**
     * NavDestinations should be created via [Navigator.createDestination].
     *
     * This constructor requires that the given Navigator has a [Navigator.Name] annotation.
     */
    public constructor(navigator: Navigator<out NavDestination>) : this(
        NavigatorProvider.getNameForNavigator(
            navigator.javaClass
        )
    )

    /**
     * Called when inflating a destination from a resource.
     *
     * @param context local context performing inflation
     * @param attrs attrs to parse during inflation
     */
    @CallSuper
    public open fun onInflate(context: Context, attrs: AttributeSet) {
        context.resources.obtainAttributes(attrs, R.styleable.Navigator).use { array ->
            route = array.getString(R.styleable.Navigator_route)

            if (array.hasValue(R.styleable.Navigator_android_id)) {
                id = array.getResourceId(R.styleable.Navigator_android_id, 0)
                idName = getDisplayName(context, id)
            }
            label = array.getText(R.styleable.Navigator_android_label)
        }
    }

    /**
     * The destination's unique ID. This should be an ID resource generated by
     * the Android resource system.
     */
    @get:IdRes
    public var id: Int = 0
        set(@IdRes id) {
            field = id
            idName = null
        }

    /**
     * The destination's unique route. Setting this will also update the [id] of the destinations
     * so custom destination ids should only be set after setting the route.
     *
     * @return this destination's route, or null if no route is set
     *
     * @throws IllegalArgumentException is the given route is empty
     */
    public var route: String? = null
        set(route) {
            if (route == null) {
                id = 0
            } else {
                require(route.isNotBlank()) { "Cannot have an empty route" }
                val internalRoute = createRoute(route)
                id = internalRoute.hashCode()
                addDeepLink(internalRoute)
            }
            deepLinks.remove(deepLinks.firstOrNull { it.uriPattern == createRoute(field) })
            field = route
        }

    /**
     * @hide
     */
    @get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public open val displayName: String
        get() = idName ?: id.toString()

    /**
     * Checks the given deep link [Uri], and determines whether it matches a Uri pattern added
     * to the destination by a call to [addDeepLink] . It returns `true`
     * if the deep link is a valid match, and `false` otherwise.
     *
     * This should be called prior to [NavController.navigate] to ensure the deep link
     * can be navigated to.
     *
     * @param deepLink to the destination reachable from the current NavGraph
     * @return True if the deepLink exists for the destination.
     * @see NavDestination.addDeepLink
     * @see NavController.navigate
     * @see NavDestination.hasDeepLink
     */
    public open fun hasDeepLink(deepLink: Uri): Boolean {
        return hasDeepLink(NavDeepLinkRequest(deepLink, null, null))
    }

    /**
     * Checks the given [NavDeepLinkRequest], and determines whether it matches a
     * [NavDeepLink] added to the destination by a call to
     * [addDeepLink]. It returns `true` if the request is a valid
     * match, and `false` otherwise.
     *
     * This should be called prior to [NavController.navigate] to
     * ensure the deep link can be navigated to.
     *
     * @param deepLinkRequest to the destination reachable from the current NavGraph
     * @return True if the deepLink exists for the destination.
     * @see NavDestination.addDeepLink
     * @see NavController.navigate
     */
    public open fun hasDeepLink(deepLinkRequest: NavDeepLinkRequest): Boolean {
        return matchDeepLink(deepLinkRequest) != null
    }

    /**
     * Add a deep link to this destination. Matching Uris sent to
     * [NavController.handleDeepLink] or [NavController.navigate] will
     * trigger navigating to this destination.
     *
     * In addition to a direct Uri match, the following features are supported:
     *
     * - Uris without a scheme are assumed as http and https. For example,
     * `www.example.com` will match `http://www.example.com` and
     * `https://www.example.com`.
     * - Placeholders in the form of `{placeholder_name}` matches 1 or more
     * characters. The parsed value of the placeholder will be available in the arguments
     * [Bundle] with a key of the same name. For example,
     * `http://www.example.com/users/{id}` will match
     * `http://www.example.com/users/4`.
     * - The `.*` wildcard can be used to match 0 or more characters.
     *
     * These Uris can be declared in your navigation XML files by adding one or more
     * `<deepLink app:uri="uriPattern" />` elements as
     * a child to your destination.
     *
     * Deep links added in navigation XML files will automatically replace instances of
     * `${applicationId}` with the applicationId of your app.
     * Programmatically added deep links should use [Context.getPackageName] directly
     * when constructing the uriPattern.
     * @param uriPattern The uri pattern to add as a deep link
     * @see NavController.handleDeepLink
     * @see NavController.navigate
     * @see NavDestination.addDeepLink
     */
    public fun addDeepLink(uriPattern: String) {
        addDeepLink(NavDeepLink.Builder().setUriPattern(uriPattern).build())
    }

    /**
     * Add a deep link to this destination. Uris that match the given [NavDeepLink] uri
     * sent to [NavController.handleDeepLink] or
     * [NavController.navigate] will trigger navigating to this
     * destination.
     *
     * In addition to a direct Uri match, the following features are supported:
     *
     * Uris without a scheme are assumed as http and https. For example,
     * `www.example.com` will match `http://www.example.com` and
     * `https://www.example.com`.
     * Placeholders in the form of `{placeholder_name}` matches 1 or more
     * characters. The String value of the placeholder will be available in the arguments
     * [Bundle] with a key of the same name. For example,
     * `http://www.example.com/users/{id}` will match
     * `http://www.example.com/users/4`.
     * The `.*` wildcard can be used to match 0 or more characters.
     *
     * These Uris can be declared in your navigation XML files by adding one or more
     * `<deepLink app:uri="uriPattern" />` elements as
     * a child to your destination.
     *
     * Custom actions and mimetypes are also supported by [NavDeepLink] and can be declared
     * in your navigation XML files by adding
     * `<app:action="android.intent.action.SOME_ACTION" />` or
     * `<app:mimetype="type/subtype" />` as part of your deepLink declaration.
     *
     * Deep link Uris, actions, and mimetypes added in navigation XML files will automatically
     * replace instances of `${applicationId}` with the applicationId of your app.
     * Programmatically added deep links should use [Context.getPackageName] directly
     * when constructing the uriPattern.
     *
     * When matching deep links for calls to [NavController.handleDeepLink] or
     * [NavController.navigate] the order of precedence is as follows:
     * the deep link with the most matching arguments will be chosen, followed by the deep link
     * with a matching action, followed by the best matching mimeType (e.i. when matching
     * mimeType image/jpg: image/ * > *\/jpg > *\/ *).
     * @param navDeepLink The NavDeepLink to add as a deep link
     * @see NavController.handleDeepLink
     * @see NavController.navigate
     */
    public fun addDeepLink(navDeepLink: NavDeepLink) {
        val missingRequiredArguments =
            arguments.filterValues { !it.isNullable && !it.isDefaultValuePresent }
                .keys
                .filter { it !in navDeepLink.argumentsNames }
        require(missingRequiredArguments.isEmpty()) {
            "Deep link ${navDeepLink.uriPattern} can't be used to open destination $this.\n" +
                "Following required arguments are missing: $missingRequiredArguments"
        }

        deepLinks.add(navDeepLink)
    }

    /**
     * Determines if this NavDestination has a deep link matching the given Uri.
     * @param navDeepLinkRequest The request to match against all deep links added in
     * [addDeepLink]
     * @return The matching [NavDestination] and the appropriate [Bundle] of arguments
     * extracted from the Uri, or null if no match was found.
     * @suppress
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public open fun matchDeepLink(navDeepLinkRequest: NavDeepLinkRequest): DeepLinkMatch? {
        if (deepLinks.isEmpty()) {
            return null
        }
        var bestMatch: DeepLinkMatch? = null
        for (deepLink in deepLinks) {
            val uri = navDeepLinkRequest.uri
            // includes matching args for path, query, and fragment
            val matchingArguments =
                if (uri != null) deepLink.getMatchingArguments(uri, arguments) else null
            val matchingPathSegments = deepLink.calculateMatchingPathSegments(uri)
            val requestAction = navDeepLinkRequest.action
            val matchingAction = requestAction != null && requestAction ==
                deepLink.action
            val mimeType = navDeepLinkRequest.mimeType
            val mimeTypeMatchLevel =
                if (mimeType != null) deepLink.getMimeTypeMatchRating(mimeType) else -1
            if (matchingArguments != null || matchingAction || mimeTypeMatchLevel > -1) {
                val newMatch = DeepLinkMatch(
                    this, matchingArguments,
                    deepLink.isExactDeepLink, matchingPathSegments, matchingAction,
                    mimeTypeMatchLevel
                )
                if (bestMatch == null || newMatch > bestMatch) {
                    bestMatch = newMatch
                }
            }
        }
        return bestMatch
    }

    /**
     * Build an array containing the hierarchy from the root down to this destination.
     *
     * @param previousDestination the previous destination we are starting at
     * @return An array containing all of the ids from the previous destination (or the root of
     * the graph if null) to this destination
     * @suppress
     */
    @JvmOverloads
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun buildDeepLinkIds(previousDestination: NavDestination? = null): IntArray {
        val hierarchy = ArrayDeque<NavDestination>()
        var current: NavDestination? = this
        do {
            val parent = current!!.parent
            if (
                // If the current destination is a sibling of the previous, just add it straightaway
                previousDestination?.parent != null &&
                previousDestination.parent!!.findNode(current.id) === current
            ) {
                hierarchy.addFirst(current)
                break
            }
            if (parent == null || parent.startDestinationId != current.id) {
                hierarchy.addFirst(current)
            }
            if (parent == previousDestination) {
                break
            }
            current = parent
        } while (current != null)
        return hierarchy.toList().map { it.id }.toIntArray()
    }

    /**
     * @return Whether this NavDestination supports outgoing actions
     * @see NavDestination.putAction
     * @suppress
     */
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public open fun supportsActions(): Boolean {
        return true
    }

    /**
     * Returns the [NavAction] for the given action ID. This will recursively check the
     * [parent][getParent] of this destination if the action destination is not found in
     * this destination.
     *
     * @param id action ID to fetch
     * @return the [NavAction] mapped to the given action id, or null if one has not been set
     */
    public fun getAction(@IdRes id: Int): NavAction? {
        val destination = if (actions.isEmpty) null else actions[id]
        // Search the parent for the given action if it is not found in this destination
        return destination ?: parent?.run { getAction(id) }
    }

    /**
     * Creates a [NavAction] for the given [destId] and associates it with the [actionId].
     *
     * @param actionId action ID to bind
     * @param destId destination ID for the given action
     */
    public fun putAction(@IdRes actionId: Int, @IdRes destId: Int) {
        putAction(actionId, NavAction(destId))
    }

    /**
     * Sets the [NavAction] destination for an action ID.
     *
     * @param actionId action ID to bind
     * @param action action to associate with this action ID
     * @throws UnsupportedOperationException this destination is considered a terminal destination
     * and does not support actions
     */
    public fun putAction(@IdRes actionId: Int, action: NavAction) {
        if (!supportsActions()) {
            throw UnsupportedOperationException(
                "Cannot add action $actionId to $this as it does not support actions, " +
                    "indicating that it is a terminal destination in your navigation graph and " +
                    "will never trigger actions."
            )
        }
        require(actionId != 0) { "Cannot have an action with actionId 0" }
        actions.put(actionId, action)
    }

    /**
     * Unsets the [NavAction] for an action ID.
     *
     * @param actionId action ID to remove
     */
    public fun removeAction(@IdRes actionId: Int) {
        actions.remove(actionId)
    }

    /**
     * Sets an argument type for an argument name
     *
     * @param argumentName argument object to associate with destination
     * @param argument argument object to associate with destination
     */
    public fun addArgument(argumentName: String, argument: NavArgument) {
        _arguments[argumentName] = argument
    }

    /**
     * Unsets the argument type for an argument name.
     *
     * @param argumentName argument to remove
     */
    public fun removeArgument(argumentName: String) {
        _arguments.remove(argumentName)
    }

    /**
     * Combines the default arguments for this destination with the arguments provided
     * to construct the final set of arguments that should be used to navigate
     * to this destination.
     * @suppress
     */
    @Suppress("NullableCollection") // Needed for nullable bundle
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun addInDefaultArgs(args: Bundle?): Bundle? {
        if (args == null && _arguments.isNullOrEmpty()) {
            return null
        }
        val defaultArgs = Bundle()
        for ((key, value) in _arguments) {
            value.putDefaultValue(key, defaultArgs)
        }
        if (args != null) {
            defaultArgs.putAll(args)
            for ((key, value) in _arguments) {
                require(value.verify(key, defaultArgs)) {
                    "Wrong argument type for '$key' in argument bundle. ${value.type.name} " +
                        "expected."
                }
            }
        }
        return defaultArgs
    }

    /**
     * Parses a dynamic label containing arguments into a String.
     *
     * Supports String Resource arguments by parsing `R.string` values of `ReferenceType`
     * arguments found in `android:label` into their String values.
     *
     * Returns `null` if label is null.
     *
     * Returns the original label if the label was a static string.
     *
     * @param context Context used to resolve a resource's name
     * @param bundle Bundle containing the arguments used in the label
     * @return The parsed string or null if the label is null
     * @throws IllegalArgumentException if an argument provided in the label cannot be found in
     * the bundle, or if the label contains a string template but the bundle is null
     */
    public fun fillInLabel(context: Context, bundle: Bundle?): String? {
        val label = label ?: return null

        val fillInPattern = Pattern.compile("\{(.+?)\}")
        val matcher = fillInPattern.matcher(label)
        val builder = StringBuffer()

        while (matcher.find()) {
            val argName = matcher.group(1)
            if (bundle != null && bundle.containsKey(argName)) {
                matcher.appendReplacement(builder, "")
                val argType = argName?.let { arguments[argName]?.type }
                if (argType == NavType.ReferenceType) {
                    val value = context.getString(bundle.getInt(argName))
                    builder.append(value)
                } else {
                    builder.append(bundle.getString(argName))
                }
            } else {
                throw IllegalArgumentException(
                    "Could not find \"$argName\" in $bundle to fill label \"$label\""
                )
            }
        }
        matcher.appendTail(builder)
        return builder.toString()
    }

    override fun toString(): String {
        val sb = StringBuilder()
        sb.append(javaClass.simpleName)
        sb.append("(")
        if (idName == null) {
            sb.append("0x")
            sb.append(Integer.toHexString(id))
        } else {
            sb.append(idName)
        }
        sb.append(")")
        if (!route.isNullOrBlank()) {
            sb.append(" route=")
            sb.append(route)
        }
        if (label != null) {
            sb.append(" label=")
            sb.append(label)
        }
        return sb.toString()
    }

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is NavDestination) return false

        val equalDeepLinks = deepLinks.intersect(other.deepLinks).size == deepLinks.size

        val equalActions = actions.size() == other.actions.size() &&
            actions.valueIterator().asSequence().all { other.actions.containsValue(it) } &&
            other.actions.valueIterator().asSequence().all { actions.containsValue(it) }

        val equalArguments = arguments.size == other.arguments.size &&
            arguments.asSequence().all {
                other.arguments.containsKey(it.key) &&
                    other.arguments[it.key] == it.value
            } &&
            other.arguments.asSequence().all {
                arguments.containsKey(it.key) &&
                    arguments[it.key] == it.value
            }

        return id == other.id &&
            route == other.route &&
            equalDeepLinks &&
            equalActions &&
            equalArguments
    }

    @Suppress("DEPRECATION")
    override fun hashCode(): Int {
        var result = id
        result = 31 * result + route.hashCode()
        deepLinks.forEach {
            result = 31 * result + it.uriPattern.hashCode()
            result = 31 * result + it.action.hashCode()
            result = 31 * result + it.mimeType.hashCode()
        }
        actions.valueIterator().forEach { value ->
            result = 31 * result + value.destinationId
            result = 31 * result + value.navOptions.hashCode()
            value.defaultArguments?.keySet()?.forEach {
                result = 31 * result + value.defaultArguments!!.get(it).hashCode()
            }
        }
        arguments.keys.forEach {
            result = 31 * result + it.hashCode()
            result = 31 * result + arguments[it].hashCode()
        }
        return result
    }

    public companion object {
        private val classes = mutableMapOf<String, Class<*>>()

        /**
         * Parse the class associated with this destination from a raw name, generally extracted
         * from the `android:name` attribute added to the destination's XML. This should
         * be the class providing the visual representation of the destination that the
         * user sees after navigating to this destination.
         *
         * This method does name -> Class caching and should be strongly preferred over doing your
         * own parsing if your [Navigator] supports the `android:name` attribute to
         * give consistent behavior across all Navigators.
         *
         * @param context Context providing the package name for use with relative class names and the
         * ClassLoader
         * @param name Absolute or relative class name. Null names will be ignored.
         * @param expectedClassType The expected class type
         * @return The parsed class
         * @throws IllegalArgumentException if the class is not found in the provided Context's
         * ClassLoader or if the class is not of the expected type
         */
        @Suppress("UNCHECKED_CAST")
        @JvmStatic
        protected fun <C> parseClassFromName(
            context: Context,
            name: String,
            expectedClassType: Class<out C?>
        ): Class<out C?> {
            var innerName = name
            if (innerName[0] == '.') {
                innerName = context.packageName + innerName
            }
            var clazz = classes[innerName]
            if (clazz == null) {
                try {
                    clazz = Class.forName(innerName, true, context.classLoader)
                    classes[name] = clazz
                } catch (e: ClassNotFoundException) {
                    throw IllegalArgumentException(e)
                }
            }
            require(expectedClassType.isAssignableFrom(clazz!!)) {
                "$innerName must be a subclass of $expectedClassType"
            }
            return clazz as Class<out C?>
        }

        /**
         * Used internally for NavDestinationTest
         * @suppress
         */
        @JvmStatic
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        public fun <C> parseClassFromNameInternal(
            context: Context,
            name: String,
            expectedClassType: Class<out C?>
        ): Class<out C?> {
            return parseClassFromName(context, name, expectedClassType)
        }

        /**
         * Retrieve a suitable display name for a given id.
         * @param context Context used to resolve a resource's name
         * @param id The id to get a display name for
         * @return The resource's name if it is a valid id or just the id itself if it is not
         * a valid resource
         * @hide
         */
        @JvmStatic
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        public fun getDisplayName(context: Context, id: Int): String {
            // aapt-generated IDs have the high byte nonzero,
            // so anything below that cannot be a valid resource id
            return if (id <= 0x00FFFFFF) {
                id.toString()
            } else try {
                context.resources.getResourceName(id)
            } catch (e: Resources.NotFoundException) {
                id.toString()
            }
        }

        /**
         * @hide
         */
        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
        public fun createRoute(route: String?): String =
            if (route != null) "android-app://androidx.navigation/$route" else ""

        /**
         * Provides a sequence of the NavDestination's hierarchy. The hierarchy starts with this
         * destination itself and is then followed by this destination's [NavDestination.parent], then that
         * graph's parent, and up the hierarchy until you've reached the root navigation graph.
         */
        @JvmStatic
        public val NavDestination.hierarchy: Sequence<NavDestination>
            get() = generateSequence(this) { it.parent }
    }
}