Skip to content

Commit a9dc248

Browse files
committed
Restructure the deeplink artifact
1. Simplified the base package from "deeplink.parseIntent.singleModule" to "deeplink.basic". 2. Added `ui` and `deeplinkutil` packages to separate sample ui code from parsing/matching helpers 3. Separate classes within DeepLinkUtil into their own separate files 4. Separate DeepLinkRequest into two separate classes - DeepLinkRequest: parse uri and store parse result - DeepLinkMatcher: takes a request + pattern and matches the two 5. Change type <T> upper bound to NavKey to make the helpers more general
1 parent 68c060d commit a9dc248

File tree

11 files changed

+362
-300
lines changed

11 files changed

+362
-300
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,12 @@
141141
android:exported="true"
142142
android:theme="@style/Theme.Nav3Recipes"/>
143143
<activity
144-
android:name=".deeplink.parseintent.singleModule.CreateDeepLinkActivity"
144+
android:name=".deeplink.basic.CreateDeepLinkActivity"
145145
android:exported="true"
146146
android:theme="@style/Theme.Nav3Recipes">
147147
</activity>
148148
<activity
149-
android:name=".deeplink.parseintent.singleModule.MainActivity"
149+
android:name=".deeplink.basic.MainActivity"
150150
android:exported="true"
151151
android:theme="@style/Theme.Nav3Recipes">
152152
<intent-filter android:autoVerify="true">

app/src/main/java/com/example/nav3recipes/RecipePickerActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import com.example.nav3recipes.basicdsl.BasicDslActivity
3131
import com.example.nav3recipes.basicsaveable.BasicSaveableActivity
3232
import com.example.nav3recipes.commonui.CommonUiActivity
3333
import com.example.nav3recipes.conditional.ConditionalActivity
34-
import com.example.nav3recipes.deeplink.parseintent.singleModule.CreateDeepLinkActivity
34+
import com.example.nav3recipes.deeplink.basic.CreateDeepLinkActivity
3535
import com.example.nav3recipes.dialog.DialogActivity
3636
import com.example.nav3recipes.modular.hilt.ModularActivity
3737
import com.example.nav3recipes.passingarguments.viewmodels.basic.BasicViewModelsActivity
Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.example.nav3recipes.deeplink.parseintent.singleModule
1+
package com.example.nav3recipes.deeplink.basic
22

33
import android.os.Bundle
44
import androidx.activity.ComponentActivity
@@ -9,6 +9,26 @@ import androidx.compose.runtime.mutableStateMapOf
99
import androidx.compose.runtime.mutableStateOf
1010
import androidx.compose.runtime.remember
1111
import androidx.compose.runtime.setValue
12+
import com.example.nav3recipes.deeplink.basic.ui.DeepLinkButton
13+
import com.example.nav3recipes.deeplink.basic.ui.EMPTY
14+
import com.example.nav3recipes.deeplink.basic.ui.EntryScreen
15+
import com.example.nav3recipes.deeplink.basic.ui.FIRST_NAME_JOHN
16+
import com.example.nav3recipes.deeplink.basic.ui.FIRST_NAME_JULIE
17+
import com.example.nav3recipes.deeplink.basic.ui.FIRST_NAME_MARY
18+
import com.example.nav3recipes.deeplink.basic.ui.FIRST_NAME_TOM
19+
import com.example.nav3recipes.deeplink.basic.ui.LOCATION_BC
20+
import com.example.nav3recipes.deeplink.basic.ui.LOCATION_BR
21+
import com.example.nav3recipes.deeplink.basic.ui.LOCATION_CA
22+
import com.example.nav3recipes.deeplink.basic.ui.LOCATION_US
23+
import com.example.nav3recipes.deeplink.basic.ui.MenuDropDown
24+
import com.example.nav3recipes.deeplink.basic.ui.MenuTextInput
25+
import com.example.nav3recipes.deeplink.basic.ui.PATH_BASE
26+
import com.example.nav3recipes.deeplink.basic.ui.PATH_INCLUDE
27+
import com.example.nav3recipes.deeplink.basic.ui.PATH_SEARCH
28+
import com.example.nav3recipes.deeplink.basic.ui.STRING_LITERAL_HOME
29+
import com.example.nav3recipes.deeplink.basic.ui.SearchKey
30+
import com.example.nav3recipes.deeplink.basic.ui.TextContent
31+
import com.example.nav3recipes.deeplink.basic.ui.UsersKey
1232

1333
/**
1434
* This activity allows the user to create a deep link and make a request with it
@@ -24,7 +44,7 @@ class CreateDeepLinkActivity : ComponentActivity() {
2444
* UI for deeplink sandbox
2545
*/
2646
EntryScreen("Sandbox - Build Your Deeplink") {
27-
TextContent("Base url:\n$PATH_BASE/")
47+
TextContent("Base url:\n${PATH_BASE}/")
2848
var showFilterOptions by remember { mutableStateOf(false) }
2949
val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) }
3050

@@ -42,10 +62,12 @@ class CreateDeepLinkActivity : ComponentActivity() {
4262
showQueryOptions = true
4363
showFilterOptions = false
4464
}
65+
4566
PATH_INCLUDE -> {
4667
showQueryOptions = false
4768
showFilterOptions = true
4869
}
70+
4971
else -> {
5072
showQueryOptions = false
5173
showFilterOptions = false
@@ -64,7 +86,7 @@ class CreateDeepLinkActivity : ComponentActivity() {
6486
if (showFilterOptions) {
6587
MenuDropDown(
6688
menuOptions = MENU_OPTIONS_FILTER,
67-
) { _, selected ->
89+
) { _, selected ->
6890
selectedFilter = selected
6991
}
7092
}
@@ -104,9 +126,10 @@ class CreateDeepLinkActivity : ComponentActivity() {
104126
}
105127
}
106128
}
129+
107130
else -> ""
108131
}
109-
val finalUrl = "$PATH_BASE/${selectedPath.value}$arguments"
132+
val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments"
110133
TextContent("Final url:\n$finalUrl")
111134
// deeplink to target
112135
DeepLinkButton(
@@ -133,7 +156,13 @@ private val MENU_OPTIONS_FILTER = mapOf(
133156
)
134157

135158
private val MENU_OPTIONS_SEARCH = mapOf(
136-
SearchKey::firstName.name to listOf(EMPTY, FIRST_NAME_JOHN, FIRST_NAME_TOM, FIRST_NAME_MARY, FIRST_NAME_JULIE),
159+
SearchKey::firstName.name to listOf(
160+
EMPTY,
161+
FIRST_NAME_JOHN,
162+
FIRST_NAME_TOM,
163+
FIRST_NAME_MARY,
164+
FIRST_NAME_JULIE
165+
),
137166
SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US)
138167
)
139168

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package com.example.nav3recipes.deeplink.parseintent.singleModule
1+
package com.example.nav3recipes.deeplink.basic
22

33
import android.net.Uri
44
import android.os.Bundle
@@ -10,6 +10,21 @@ import androidx.navigation3.runtime.NavKey
1010
import androidx.navigation3.runtime.entryProvider
1111
import androidx.navigation3.runtime.rememberNavBackStack
1212
import androidx.navigation3.ui.NavDisplay
13+
import com.example.nav3recipes.deeplink.basic.deeplinkutil.DeepLinkMatcher
14+
import com.example.nav3recipes.deeplink.basic.deeplinkutil.DeepLinkPattern
15+
import com.example.nav3recipes.deeplink.basic.deeplinkutil.DeepLinkRequest
16+
import com.example.nav3recipes.deeplink.basic.deeplinkutil.DeepLinkMatchResult
17+
import com.example.nav3recipes.deeplink.basic.deeplinkutil.KeyDecoder
18+
import com.example.nav3recipes.deeplink.basic.ui.EntryScreen
19+
import com.example.nav3recipes.deeplink.basic.ui.FriendsList
20+
import com.example.nav3recipes.deeplink.basic.ui.HomeKey
21+
import com.example.nav3recipes.deeplink.basic.ui.LIST_USERS
22+
import com.example.nav3recipes.deeplink.basic.ui.SearchKey
23+
import com.example.nav3recipes.deeplink.basic.ui.TextContent
24+
import com.example.nav3recipes.deeplink.basic.ui.URL_HOME_EXACT
25+
import com.example.nav3recipes.deeplink.basic.ui.URL_SEARCH
26+
import com.example.nav3recipes.deeplink.basic.ui.URL_USERS_WITH_FILTER
27+
import com.example.nav3recipes.deeplink.basic.ui.UsersKey
1328

1429
/**
1530
* Parses a target deeplink into a NavKey. There are several crucial steps involved:
@@ -37,7 +52,7 @@ import androidx.navigation3.ui.NavDisplay
3752
*/
3853
class MainActivity : ComponentActivity() {
3954
/** STEP 1. Parse supported deeplinks */
40-
private val deepLinkPatterns: List<DeepLinkPattern<out NavRecipeKey>> = listOf(
55+
private val deepLinkPatterns: List<DeepLinkPattern<out NavKey>> = listOf(
4156
// "https://www.nav3recipes.com/home"
4257
DeepLinkPattern(HomeKey.serializer(), (URL_HOME_EXACT).toUri()),
4358
// "https://www.nav3recipes.com/users/with/{filter}"
@@ -54,10 +69,10 @@ class MainActivity : ComponentActivity() {
5469
// associate the target with the correct backstack key
5570
val key: NavKey = uri?.let {
5671
/** STEP 2. Parse requested deeplink */
57-
val target = DeepLinkRequest(uri)
72+
val request = DeepLinkRequest(uri)
5873
/** STEP 3. Compared requested with supported deeplink to find match*/
59-
val match = deepLinkPatterns.firstNotNullOfOrNull { candidate ->
60-
target.match(candidate)
74+
val match = deepLinkPatterns.firstNotNullOfOrNull { pattern ->
75+
DeepLinkMatcher(request, pattern).match()
6176
}
6277
/** STEP 4. If match is found, associate match to the correct key*/
6378
match?.let {
@@ -98,9 +113,9 @@ class MainActivity : ComponentActivity() {
98113
TextContent("<matches query parameters, if any>")
99114
val matchingUsers = LIST_USERS.filter { user ->
100115
(search.firstName == null || user.firstName == search.firstName) &&
101-
(search.location == null || user.location == search.location) &&
102-
(search.ageMin == null || user.age >= search.ageMin) &&
103-
(search.ageMax == null || user.age <= search.ageMax)
116+
(search.location == null || user.location == search.location) &&
117+
(search.ageMin == null || user.age >= search.ageMin) &&
118+
(search.ageMax == null || user.age <= search.ageMax)
104119
}
105120
FriendsList(matchingUsers)
106121
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package com.example.nav3recipes.deeplink.basic.deeplinkutil
2+
3+
import android.util.Log
4+
import androidx.navigation3.runtime.NavKey
5+
import kotlinx.serialization.KSerializer
6+
7+
internal class DeepLinkMatcher<T : NavKey>(
8+
val request: DeepLinkRequest,
9+
val deepLinkPattern: DeepLinkPattern<T>
10+
) {
11+
/**
12+
* Match a [DeepLinkRequest] to a [DeepLinkPattern].
13+
*
14+
* Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
15+
*/
16+
fun match(): DeepLinkMatchResult<T>? {
17+
if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null
18+
// exact match (url does not contain any arguments)
19+
if (request.uri == deepLinkPattern.uriPattern)
20+
return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf())
21+
22+
val args = mutableMapOf<String, Any>()
23+
// match the path
24+
request.pathSegments
25+
.asSequence()
26+
// zip to compare the two objects side by side, order matters here so we
27+
// need to make sure the compared segments are at the same position within the url
28+
.zip(deepLinkPattern.pathSegments.asSequence())
29+
.forEach { it ->
30+
// retrieve the two path segments to compare
31+
val requestedSegment = it.first
32+
val candidateSegment = it.second
33+
// if the potential match expects a path arg for this segment, try to parse the
34+
// requested segment into the expected type
35+
if (candidateSegment.isParamArg) {
36+
val parsedValue = try {
37+
candidateSegment.typeParser.invoke(requestedSegment)
38+
} catch (e: IllegalArgumentException) {
39+
Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e)
40+
return null
41+
}
42+
args[candidateSegment.stringValue] = parsedValue
43+
} else if(requestedSegment != candidateSegment.stringValue){
44+
// if it's path arg is not the expected type, its not a match
45+
return null
46+
}
47+
}
48+
// match queries (if any)
49+
request.queries.forEach { query ->
50+
val name = query.key
51+
val queryStringParser = deepLinkPattern.queryValueParsers[name]
52+
val queryParsedValue = try {
53+
queryStringParser!!.invoke(query.value)
54+
} catch (e: IllegalArgumentException) {
55+
Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e)
56+
return null
57+
}
58+
args[name] = queryParsedValue
59+
}
60+
// provide the serializer of the matching key and map of arg names to parsed arg values
61+
return DeepLinkMatchResult(deepLinkPattern.serializer, args)
62+
}
63+
}
64+
65+
66+
/**
67+
* Created when a requested deeplink matches with a supported deeplink
68+
*
69+
* @param [T] the backstack key associated with the deeplink that matched with the requested deeplink
70+
* @param serializer serializer for [T]
71+
* @param args The map of argument name to argument value. The value is expected to have already
72+
* been parsed from the raw url string back into its proper KType as declared in [T].
73+
* Includes arguments for all parts of the uri - path, query, etc.
74+
* */
75+
internal data class DeepLinkMatchResult<T : NavKey>(
76+
val serializer: KSerializer<T>,
77+
val args: Map<String, Any>
78+
)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package com.example.nav3recipes.deeplink.basic.deeplinkutil
2+
3+
import android.net.Uri
4+
import androidx.navigation3.runtime.NavKey
5+
import com.example.nav3recipes.deeplink.basic.ui.NavRecipeKey
6+
import kotlinx.serialization.KSerializer
7+
import kotlinx.serialization.descriptors.PrimitiveKind
8+
import kotlinx.serialization.descriptors.SerialKind
9+
import java.io.Serializable
10+
11+
/**
12+
* Parse a supported deeplink and stores its metadata as a easily readable format
13+
*
14+
* The following notes applies specifically to this particular sample implementation:
15+
*
16+
* The supported deeplink is expected to be built from a serializable backstack key [T] that
17+
* supports deeplink. This means that if this deeplink contains any arguments (path or query),
18+
* the argument name must match any of [T] member field name.
19+
*
20+
* One [DeepLinkPattern] should be created for each supported deeplink. This means if [T]
21+
* supports two deeplink patterns:
22+
* ```
23+
* val deeplink1 = www.nav3recipes.com/home
24+
* val deeplink2 = www.nav3recipes.com/profile/{userId}
25+
* ```
26+
* Then two [DeepLinkPattern] should be created
27+
* ```
28+
* val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1)
29+
* val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2)
30+
* ```
31+
*
32+
* This implementation assumes a few things:
33+
* 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match
34+
* 2. all query arguments are optional by way of nullable/has default value
35+
*
36+
* @param T the backstack key type that supports the deeplinking of [uriPattern]
37+
* @param serializer the serializer of [T]
38+
* @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}"
39+
*/
40+
internal class DeepLinkPattern<T : NavKey>(
41+
val serializer: KSerializer<T>,
42+
val uriPattern: Uri
43+
) {
44+
/**
45+
* Help differentiate if a path segment is an argument or a static value
46+
*/
47+
private val regexPatternFillIn = Regex("\\{(.+?)\\}")
48+
49+
// TODO make these lazy
50+
/**
51+
* parse the path into a list of [PathSegment]
52+
*
53+
* order matters here - path segments need to match in value and order when matching
54+
* requested deeplink to supported deeplink
55+
*/
56+
val pathSegments: List<PathSegment> = buildList {
57+
uriPattern.pathSegments.forEach { segment ->
58+
// first, check if it is a path arg
59+
var result = regexPatternFillIn.find(segment)
60+
if (result != null) {
61+
// if so, extract the path arg name (the string value within the curly braces)
62+
val argName = result.groups[1]!!.value
63+
// from [T], read the primitive type of this argument to get the correct type parser
64+
val elementIndex = serializer.descriptor.getElementIndex(argName)
65+
val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
66+
// finally, add the arg name and its respective type parser to the map
67+
add(PathSegment(argName, true,getTypeParser(elementDescriptor.kind)))
68+
} else {
69+
// if its not a path arg, then its just a static string path segment
70+
add(PathSegment(segment,false, getTypeParser(PrimitiveKind.STRING)))
71+
}
72+
}
73+
}
74+
75+
/**
76+
* Parse supported queries into a map of queryParameterNames to [TypeParser]
77+
*
78+
* This will be used later on to parse a provided query value into the correct KType
79+
*/
80+
val queryValueParsers: Map<String, TypeParser> = buildMap {
81+
uriPattern.queryParameterNames.forEach { paramName ->
82+
val elementIndex = serializer.descriptor.getElementIndex(paramName)
83+
val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
84+
this[paramName] = getTypeParser(elementDescriptor.kind)
85+
}
86+
}
87+
88+
/**
89+
* Metadata about a supported path segment
90+
*/
91+
class PathSegment(
92+
val stringValue: String,
93+
val isParamArg: Boolean,
94+
val typeParser: TypeParser
95+
)
96+
}
97+
98+
/**
99+
* Parses a String into a Serializable Primitive
100+
*/
101+
private typealias TypeParser = (String) -> Serializable
102+
103+
private fun getTypeParser(kind: SerialKind): TypeParser {
104+
return when (kind) {
105+
PrimitiveKind.STRING -> Any::toString
106+
PrimitiveKind.INT -> toInt
107+
PrimitiveKind.BOOLEAN -> String::toBoolean
108+
PrimitiveKind.BYTE -> toByte
109+
PrimitiveKind.CHAR -> toChar
110+
PrimitiveKind.DOUBLE -> String::toDouble
111+
PrimitiveKind.FLOAT -> String::toFloat
112+
PrimitiveKind.LONG -> toLong
113+
PrimitiveKind.SHORT -> toShort
114+
else -> throw IllegalArgumentException(
115+
"Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive."
116+
)
117+
}
118+
}
119+
120+
val toInt: (String) -> Int = String::toInt
121+
val toByte: (String) -> Byte = String::toByte
122+
val toChar: (String) -> Char = String::first
123+
val toLong: (String) -> Long = String::toLong
124+
val toShort: (String) -> Short = String::toShort
125+
const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"

0 commit comments

Comments
 (0)