딥 링크 URI 인수 레시피

이 레시피는 Android 인텐트에서 딥 링크 URL을 파싱하여 탐색 키로 변환하는 방법을 보여줍니다.

레시피 구성요소

두 가지 활동으로 구성됩니다.

  1. UriWithArgumentsDeepLinkActivity는 딥 링크 요청을 구성하고 트리거합니다.
  2. MainActivity는 인텐트를 파싱하여 대상 탐색 키로 변환합니다.

작동 방식

MainActivity는 다음 단계에 따라 요청을 처리합니다.

  1. 딥 링크될 수 있는 각 URL 패턴에 대해 UriDeepLinkMatcher를 선언합니다. 각 일치자는 URI 패턴과 이 딥 링크를 지원하는 NavKey의 KSerializer를 허용합니다.

  2. 수신 인텐트로 DeepLinkRequest를 만듭니다.

  3. 모든 후보 UriDeepLinkMatchers를 요청과 일치시키고 결과 UriMatchResults를 비교하여 가장 적합한 일치자를 찾습니다.

  4. UriMatchResult.key에서 일치하는 키를 읽거나 일치하는 키가 없는 경우 기본 키를 사용합니다.

이 레시피는 인텐트 처리에 중점을 두며 다음과 같은 고려사항은 포함하지 않습니다.

  • 합성 백 스택 만들기
  • 멀티 모듈 설정
  • DI
  • TaskStack 관리
  • 위로 버튼과 뒤로 버튼

MainActivity에는 지원되는 다양한 유형의 딥 링크를 보여주는 여러 백 스택 키가 있습니다.

  1. HomeKey - 정확한 URL이 있는 딥 링크 (딥 링크 인수 없음)
  2. UsersKey - 경로 인수가 있는 딥 링크
  3. SearchKey - 쿼리 인수가 있는 딥 링크

각각의 실제 URL 패턴은 MainActivity.deepLinkMatchers를 참고하세요.

package com.example.nav3recipes.deeplink.handlerequests.uriarguments

import androidx.navigation3.runtime.NavKey
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_FILTER
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_HOME
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_SEARCH
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_USERS
import kotlinx.serialization.Serializable

internal interface NavRecipeKey: NavKey {
    val name: String
}

@Serializable
internal object HomeKey: NavRecipeKey {
    override val name: String = STRING_LITERAL_HOME
}

@Serializable
internal data class UsersKey(
    val filter: String,
): NavRecipeKey {
    override val name: String = STRING_LITERAL_USERS
    companion object {
        const val FILTER_KEY = STRING_LITERAL_FILTER
        const val FILTER_OPTION_RECENTLY_ADDED = "recentlyAdded"
        const val FILTER_OPTION_ALL = "all"
    }
}

@Serializable
internal data class SearchKey(
    val firstName: String? = null,
    val ageMin: Int? = null,
    val ageMax: Int? = null,
    val location: String? = null,
): NavRecipeKey {
    override val name: String = STRING_LITERAL_SEARCH
}
package com.example.nav3recipes.deeplink.handlerequests.uriarguments

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.net.toUri
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.deeplink.DeepLinkRequest
import androidx.navigation3.runtime.deeplink.UriDeepLinkMatcher
import androidx.navigation3.runtime.deeplink.invoke
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.common.deeplink.EntryScreen
import com.example.nav3recipes.common.deeplink.FriendsList
import com.example.nav3recipes.common.deeplink.LIST_USERS
import com.example.nav3recipes.common.deeplink.TextContent
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.URL_HOME_EXACT
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.URL_SEARCH
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.URL_USERS_WITH_FILTER
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.serializer

/**
 * See README.md for how this recipe works.
 */
class MainActivity : ComponentActivity() {
    /** STEP 1. Declare supported deep links */
    internal val deepLinkMatchers: List<UriDeepLinkMatcher<NavKey>> = listOf(
        // "https://www.nav3recipes.com/home"
        UriDeepLinkMatcher(URL_HOME_EXACT.toUri(), serializer<HomeKey>()),
        // "https://www.nav3recipes.com/users/with/{filter}"
        UriDeepLinkMatcher(URL_USERS_WITH_FILTER.toUri(), serializer<UsersKey>()),
        // "https://www.nav3recipes.com/users/search?{firstName}&{age}&{location}"
        UriDeepLinkMatcher(URL_SEARCH.toUri(), serializer<SearchKey>()),
    )

    override fun onCreate(savedInstanceState: Bundle?) {
        setEdgeToEdgeConfig()
        super.onCreate(savedInstanceState)

        /** STEP 2. Create a [DeepLinkRequest] from the intent */
        val request = DeepLinkRequest(intent)

        /** STEP 3. Match the request to the DeepLinkMatchers*/
        // First get all the possible matching UriMatchResult
        val matches = deepLinkMatchers.mapNotNull {
            // returns null if no match
            it.match(request)
        }
        // compare all matches to find best match
        val bestMatch = matches.maxOrNull()
        /** STEP 4. Get the key from the match or use default key if no match*/
        val key = bestMatch?.key ?: HomeKey

        /**
         * STEP 5. pass the initial key to backstack
         */
        setContent {
            val backStack: NavBackStack<NavKey> = rememberNavBackStack(key)
            NavDisplay(
                backStack = backStack,
                onBack = { backStack.removeLastOrNull() },
                entryProvider = entryProvider {
                    entry<HomeKey> { key ->
                        EntryScreen(key.name) {
                            TextContent("<matches exact url>")
                        }
                    }
                    entry<UsersKey> { key ->
                        EntryScreen("${key.name} : ${key.filter}") {
                            TextContent("<matches path argument>")
                            val list = when {
                                key.filter.isEmpty() -> LIST_USERS
                                key.filter == UsersKey.FILTER_OPTION_ALL -> LIST_USERS
                                else -> LIST_USERS.take(5)
                            }
                            FriendsList(list)
                        }
                    }
                    entry<SearchKey> { search ->
                        EntryScreen(search.name) {
                            TextContent("<matches query parameters, if any>")
                            val matchingUsers = LIST_USERS.filter { user ->
                                (search.firstName == null || user.firstName == search.firstName) &&
                                        (search.location == null || user.location == search.location) &&
                                        (search.ageMin == null || user.age >= search.ageMin) &&
                                        (search.ageMax == null || user.age <= search.ageMax)
                            }
                            FriendsList(matchingUsers)
                        }
                    }
                }
            )
        }
    }
}
package com.example.nav3recipes.deeplink.handlerequests.uriarguments

import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.compose.dropUnlessResumed
import com.example.nav3recipes.common.deeplink.EMPTY
import com.example.nav3recipes.common.deeplink.EntryScreen
import com.example.nav3recipes.common.deeplink.FIRST_NAME_JOHN
import com.example.nav3recipes.common.deeplink.FIRST_NAME_JULIE
import com.example.nav3recipes.common.deeplink.FIRST_NAME_MARY
import com.example.nav3recipes.common.deeplink.FIRST_NAME_TOM
import com.example.nav3recipes.common.deeplink.LOCATION_BC
import com.example.nav3recipes.common.deeplink.LOCATION_BR
import com.example.nav3recipes.common.deeplink.LOCATION_CA
import com.example.nav3recipes.common.deeplink.LOCATION_US
import com.example.nav3recipes.common.deeplink.MenuDropDown
import com.example.nav3recipes.common.deeplink.MenuTextInput
import com.example.nav3recipes.common.deeplink.PaddedButton
import com.example.nav3recipes.common.deeplink.TextContent
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.PATH_BASE
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.PATH_INCLUDE
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.PATH_SEARCH
import com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui.STRING_LITERAL_HOME
import com.example.nav3recipes.ui.setEdgeToEdgeConfig

/**
 * See README.md for how this recipe works.
 *
 * See [MainActivity] for how the requested deeplink is handled.
 */
class UriWithArgumentsDeepLinkActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        setEdgeToEdgeConfig()
        super.onCreate(savedInstanceState)

        setContent {
            /**
             * UI for deeplink sandbox
             */
            EntryScreen("Sandbox - Build Your Deeplink") {
                TextContent("Base url:\n${PATH_BASE}/")
                var showFilterOptions by remember { mutableStateOf(false) }
                val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) }

                var showQueryOptions by remember { mutableStateOf(false) }
                var selectedFilter by remember { mutableStateOf("") }
                val selectedSearchQuery = remember { mutableStateMapOf<String, String>() }

                // manage path options
                MenuDropDown(
                    menuOptions = MENU_OPTIONS_PATH,
                ) { _, selection ->
                    selectedPath.value = selection
                    when (selection) {
                        PATH_SEARCH -> {
                            showQueryOptions = true
                            showFilterOptions = false
                        }

                        PATH_INCLUDE -> {
                            showQueryOptions = false
                            showFilterOptions = true
                        }

                        else -> {
                            showQueryOptions = false
                            showFilterOptions = false
                        }
                    }
                }

                // manage path filter options, reset state if menu is closed
                LaunchedEffect(showFilterOptions) {
                    selectedFilter = if (showFilterOptions) {
                        MENU_OPTIONS_FILTER.values.first().first()
                    } else {
                        ""
                    }
                }
                if (showFilterOptions) {
                    MenuDropDown(
                        menuOptions = MENU_OPTIONS_FILTER,
                    ) { _, selected ->
                        selectedFilter = selected
                    }
                }

                // manage query options, reset state if menu is closed
                LaunchedEffect(showQueryOptions) {
                    if (showQueryOptions) {
                        val initEntry = MENU_OPTIONS_SEARCH.entries.first()
                        selectedSearchQuery[initEntry.key] = initEntry.value.first()
                    } else {
                        selectedSearchQuery.clear()
                    }
                }
                if (showQueryOptions) {
                    MenuTextInput(
                        menuLabels = MENU_LABELS_SEARCH,
                    ) { label, selected ->
                        selectedSearchQuery[label] = selected
                    }
                    MenuDropDown(
                        menuOptions = MENU_OPTIONS_SEARCH,
                    ) { label, selected ->
                        selectedSearchQuery[label] = selected
                    }
                }

                // form final deeplink url
                val arguments = when (selectedPath.value) {
                    PATH_INCLUDE -> "/${selectedFilter}"
                    PATH_SEARCH -> {
                        buildString {
                            selectedSearchQuery.forEach { entry ->
                                if (entry.value.isNotEmpty()) {
                                    val prefix = if (isEmpty()) "?" else "&"
                                    append("$prefix${entry.key}=${entry.value}")
                                }
                            }
                        }
                    }

                    else -> ""
                }
                val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments"
                TextContent("Final url:\n$finalUrl")
                // deeplink to target
                PaddedButton("Deeplink Away!", onClick = dropUnlessResumed {
                    val intent = Intent(
                        this@UriWithArgumentsDeepLinkActivity,
                        MainActivity::class.java
                    )
                    // start activity with the url
                    intent.data = finalUrl.toUri()
                    startActivity(intent)
                })
            }
        }
    }
}

private const val KEY_PATH = "path"
private val MENU_OPTIONS_PATH = mapOf(
    KEY_PATH to listOf(
        STRING_LITERAL_HOME,
        PATH_INCLUDE,
        PATH_SEARCH,
    ),
)

private val MENU_OPTIONS_FILTER = mapOf(
    UsersKey.FILTER_KEY to listOf(UsersKey.FILTER_OPTION_RECENTLY_ADDED, UsersKey.FILTER_OPTION_ALL),
)

private val MENU_OPTIONS_SEARCH = mapOf(
    SearchKey::firstName.name to listOf(
        EMPTY,
        FIRST_NAME_JOHN,
        FIRST_NAME_TOM,
        FIRST_NAME_MARY,
        FIRST_NAME_JULIE
    ),
    SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US)
)

private val MENU_LABELS_SEARCH = listOf(SearchKey::ageMin.name, SearchKey::ageMax.name)

package com.example.nav3recipes.deeplink.handlerequests.uriarguments.ui

import com.example.nav3recipes.deeplink.handlerequests.uriarguments.SearchKey

/**
 * String resources
 */
internal const val STRING_LITERAL_FILTER = "filter"
internal const val STRING_LITERAL_HOME = "home"
internal const val STRING_LITERAL_USERS = "users"
internal const val STRING_LITERAL_SEARCH = "search"
internal const val STRING_LITERAL_INCLUDE = "include"
internal const val PATH_BASE = "https://www.nav3recipes.com"
internal const val PATH_INCLUDE = "$STRING_LITERAL_USERS/$STRING_LITERAL_INCLUDE"
internal const val PATH_SEARCH = "$STRING_LITERAL_USERS/$STRING_LITERAL_SEARCH"
internal const val URL_HOME_EXACT = "$PATH_BASE/$STRING_LITERAL_HOME"

internal const val URL_USERS_WITH_FILTER = "$PATH_BASE/$PATH_INCLUDE/{$STRING_LITERAL_FILTER}"
internal val URL_SEARCH = "$PATH_BASE/$PATH_SEARCH" +
        "?${SearchKey::ageMin.name}={${SearchKey::ageMin.name}}" +
        "&${SearchKey::ageMax.name}={${SearchKey::ageMax.name}}" +
        "&${SearchKey::firstName.name}={${SearchKey::firstName.name}}" +
        "&${SearchKey::location.name}={${SearchKey::location.name}}"