NavDestinationBuilder.kt

/*
 * Copyright 2018 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 androidx.annotation.IdRes
import androidx.annotation.RestrictTo
import androidx.core.os.bundleOf
import androidx.navigation.serialization.generateNavArguments
import androidx.navigation.serialization.generateRoutePattern
import kotlin.reflect.KClass
import kotlin.reflect.KType
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer

@DslMarker
public annotation class NavDestinationDsl

/**
 * DSL for constructing a new [NavDestination]
 */
@NavDestinationDsl
public open class NavDestinationBuilder<out D : NavDestination> internal constructor(
    /**
     * The navigator the destination that will be used in [instantiateDestination]
     * to create the destination.
     */
    protected val navigator: Navigator<out D>,
    /**
     * The destination's unique ID.
     */
    @IdRes public val id: Int,
    /**
     * The destination's unique route.
     */
    public val route: String?
) {

    /**
     * DSL for constructing a new [NavDestination] with a unique id.
     *
     * This sets the destination's [route] to `null`.
     *
     * @param navigator navigator used to create the destination
     * @param id the destination's unique id
     *
     * @return the newly constructed [NavDestination]
     */
    @Deprecated(
        "Use routes to build your NavDestination instead",
        ReplaceWith("NavDestinationBuilder(navigator, route = id.toString())")
    )
    public constructor(navigator: Navigator<out D>, @IdRes id: Int) :
        this(navigator, id, null)

    /**
     * DSL for constructing a new [NavDestination] with a unique route.
     *
     * This will also update the [id] of the destination based on route.
     *
     * @param navigator navigator used to create the destination
     * @param route the destination's unique route
     *
     * @return the newly constructed [NavDestination]
     */
    public constructor(navigator: Navigator<out D>, route: String?) :
        this(navigator, -1, route)

    /**
     * DSL for constructing a new [NavDestination] with a serializable [KClass].
     *
     * This will also update the [id] of the destination based on KClass's serializer.
     *
     * @param navigator navigator used to create the destination
     * @param route the [KClass] of the destination
     * @param typeMap map of destination arguments' kotlin type [KType] to its respective custom
     * [NavType]. May be empty if destination does not use custom NavTypes.
     *
     * @return the newly constructed [NavDestination]
     */
    @OptIn(InternalSerializationApi::class)
    public constructor(
        navigator: Navigator<out D>,
        @Suppress("OptionalBuilderConstructorArgument") route: KClass<*>?,
        typeMap: Map<KType, @JvmSuppressWildcards NavType<*>>,
    ) : this(
        navigator,
        route?.serializer()?.hashCode() ?: -1,
        route?.serializer()?.generateRoutePattern(typeMap)
    ) {
        route?.apply {
            serializer().generateNavArguments(typeMap).forEach {
                arguments[it.name] = it.argument
            }
        }
        this.typeMap = typeMap
    }

    private lateinit var typeMap: Map<KType, NavType<*>>

    /**
     * The descriptive label of the destination
     */
    public var label: CharSequence? = null

    private var arguments = mutableMapOf<String, NavArgument>()

    /**
     * Add a [NavArgument] to this destination.
     */
    public fun argument(name: String, argumentBuilder: NavArgumentBuilder.() -> Unit) {
        arguments[name] = NavArgumentBuilder().apply(argumentBuilder).build()
    }

    /**
     * Add a [NavArgument] to this destination.
     */
    @Suppress("BuilderSetStyle")
    public fun argument(name: String, argument: NavArgument) {
        arguments[name] = argument
    }

    private var deepLinks = mutableListOf<NavDeepLink>()

    /**
     * Add a deep link 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.
     *
     * @param uriPattern The uri pattern to add as a deep link
     * @see deepLink
     */
    public fun deepLink(uriPattern: String) {
        deepLinks.add(NavDeepLink(uriPattern))
    }

    /**
     * Add a deep link to this destination.
     *
     * The arguments in [T] are expected to be identical (in name and type) to the arguments
     * in the [route] from KClass that was used to construct this [NavDestinationBuilder].
     *
     * Extracts deeplink arguments from [T] and appends it to the [basePath]. See docs on the
     * safe args version of [NavDeepLink.Builder.setUriPattern] for the final uriPattern's
     * generation logic.
     *
     * In addition to a direct Uri match, [basePath]s 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`.
     *
     * @param T The deepLink KClass to extract arguments from
     * @param basePath The base uri path to append arguments onto
     *
     * @see NavDeepLink.Builder.setUriPattern for the final uriPattern's
     * generation logic.
     */
    @Suppress("BuilderSetStyle")
    @JvmName("deepLinkSafeArgs")
    public inline fun <reified T : Any> deepLink(
        basePath: String,
    ) {
        deepLink(basePath, T::class) { }
    }

    /**
     * Add a deep link 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.
     *
     * @param navDeepLink the NavDeepLink to be added to this destination
     */
    public fun deepLink(navDeepLink: NavDeepLinkDslBuilder.() -> Unit) {
        deepLinks.add(NavDeepLinkDslBuilder().apply(navDeepLink).build())
    }

    /**
     * Add a deep link to this destination.
     *
     * The arguments in [T] are expected to be identical (in name and type) to the arguments
     * in the [route] from KClass that was used to construct this [NavDestinationBuilder].
     *
     * Extracts deeplink arguments from [T] and appends it to the [basePath]. See docs on the
     * safe args version of [NavDeepLink.Builder.setUriPattern] for the final uriPattern's
     * generation logic.
     *
     * In addition to a direct Uri match, [basePath]s 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`.
     *
     * @param T The deepLink KClass to extract arguments from
     * @param basePath The base uri path to append arguments onto
     * @param navDeepLink the NavDeepLink to be added to this destination
     *
     * @see NavDeepLink.Builder.setUriPattern for the final uriPattern's
     * generation logic.
     */
    @Suppress("BuilderSetStyle")
    public inline fun <reified T : Any> deepLink(
        basePath: String,
        noinline navDeepLink: NavDeepLinkDslBuilder.() -> Unit
    ) {
        deepLink(basePath, T::class, navDeepLink)
    }

    /**
     * Public delegation for the reified deepLink overloads.
     *
     * Checks for deepLink validity:
     * 1. They used the safe args constructor since we rely on that constructor
     * to add arguments to the destination
     * 2. DeepLink does not contain extra arguments not present in the destination
     * KClass. We will not have its NavType. Even if we do, the destination is not aware of the
     * argument and will just ignore it. In general we don't want safe args deeplinks to
     * introduce new arguments.
     * 3. DeepLink does not contain different argument type for the same arg name
     *
     * For the case where the deepLink is missing required arguments in the [route], existing
     * checks will catch it.
     */
    @OptIn(InternalSerializationApi::class)
    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun <T : Any> deepLink(
        basePath: String,
        route: KClass<T>,
        navDeepLink: NavDeepLinkDslBuilder.() -> Unit
    ) {
        // make sure they used the safe args constructors which automatically adds
        // argument to the destination
        check(this::typeMap.isInitialized) {
            "Cannot add deeplink from KClass [$route]. Use the NavDestinationBuilder " +
                "constructor that takes a KClass with the same arguments."
        }
        val deepLinkArgs = route.serializer().generateNavArguments(typeMap)
        deepLinkArgs.forEach {
            val arg = arguments[it.name]
            // make sure deep link doesn't contain extra arguments not present in the route KClass
            // and that it doesn't contain different arg type
            require(arg != null && arg.type == it.argument.type) {
                "Cannot add deeplink from KClass [$route]. DeepLink contains unknown argument " +
                    "[${it.name}]. Ensure deeplink arguments matches the destination's " +
                    "route from KClass"
            }
        }
        deepLink(navDeepLink(basePath, route, typeMap, navDeepLink))
    }

    /**
     * Add a deep link 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.
     *
     * @param navDeepLink the NavDeepLink to be added to this destination
     */
    @Suppress("BuilderSetStyle")
    public fun deepLink(navDeepLink: NavDeepLink) {
        deepLinks.add(navDeepLink)
    }

    private var actions = mutableMapOf<Int, NavAction>()

    /**
     * Adds a new [NavAction] to the destination
     */
    @Deprecated(
        "Building NavDestinations using IDs with the Kotlin DSL has been deprecated in " +
            "favor of using routes. When using routes there is no need for actions."
    )
    public fun action(actionId: Int, actionBuilder: NavActionBuilder.() -> Unit) {
        actions[actionId] = NavActionBuilder().apply(actionBuilder).build()
    }

    /**
     * Instantiate a new instance of [D] that will be passed to [build].
     *
     * By default, this calls [Navigator.createDestination] on [navigator], but can
     * be overridden to call a custom constructor, etc.
     */
    @Suppress("BuilderSetStyle")
    protected open fun instantiateDestination(): D = navigator.createDestination()

    /**
     * Build the NavDestination by calling [Navigator.createDestination].
     */
    public open fun build(): D {
        return instantiateDestination().also { destination ->
            destination.label = label
            arguments.forEach { (name, argument) ->
                destination.addArgument(name, argument)
            }
            deepLinks.forEach { deepLink ->
                destination.addDeepLink(deepLink)
            }
            actions.forEach { (actionId, action) ->
                destination.putAction(actionId, action)
            }
            if (route != null) {
                destination.route = route
            }
            if (id != -1) {
                destination.id = id
            }
        }
    }
}

/**
 * DSL for building a [NavAction].
 */
@NavDestinationDsl
public class NavActionBuilder {
    /**
     * The ID of the destination that should be navigated to when this action is used
     */
    public var destinationId: Int = 0

    /**
     * The set of default arguments that should be passed to the destination. The keys
     * used here should be the same as those used on the [NavDestinationBuilder.argument]
     * for the destination.
     *
     * All values added here should be able to be added to a [android.os.Bundle].
     *
     * @see NavAction.defaultArguments
     */
    public val defaultArguments: MutableMap<String, Any?> = mutableMapOf()

    private var navOptions: NavOptions? = null

    /**
     * Sets the [NavOptions] for this action that should be used by default
     */
    public fun navOptions(optionsBuilder: NavOptionsBuilder.() -> Unit) {
        navOptions = NavOptionsBuilder().apply(optionsBuilder).build()
    }

    internal fun build() = NavAction(
        destinationId, navOptions,
        if (defaultArguments.isEmpty())
            null
        else
            bundleOf(*defaultArguments.toList().toTypedArray())
    )
}

/**
 * DSL for constructing a new [NavArgument]
 */
@NavDestinationDsl
public class NavArgumentBuilder {
    private val builder = NavArgument.Builder()
    private var _type: NavType<*>? = null

    /**
     * The NavType for this argument.
     *
     * If you don't set a type explicitly, it will be inferred
     * from the default value of this argument.
     */
    public var type: NavType<*>
        set(value) {
            _type = value
            builder.setType(value)
        }
        get() {
            return _type ?: throw IllegalStateException("NavType has not been set on this builder.")
        }

    /**
     * Controls if this argument allows null values.
     */
    public var nullable: Boolean = false
        set(value) {
            field = value
            builder.setIsNullable(value)
        }

    /**
     * An optional default value for this argument.
     *
     * Any object that you set here must be compatible with [type], if it was specified.
     */
    public var defaultValue: Any? = null
        set(value) {
            field = value
            builder.setDefaultValue(value)
        }

    /**
     * Set whether there is an unknown default value present.
     *
     * Use with caution!! In general you should let [defaultValue] to automatically set this state.
     * This state should be set to true only if all these conditions are met:
     *
     * 1. There is default value present
     * 2. You do not have access to actual default value (thus you can't use [defaultValue])
     * 3. You know the default value will never ever be null if [nullable] is true.
     */
    internal var unknownDefaultValuePresent: Boolean = false
        set(value) {
            field = value
            builder.setUnknownDefaultValuePresent(value)
        }

    /**
     * Builds the NavArgument by calling [NavArgument.Builder.build].
     */
    public fun build(): NavArgument {
        return builder.build()
    }
}