Skip to content

Commit 48ec2ad

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 Other misc non-structural changes: 1. Added kdocs in CreateDeepLinkActivity to explain how this recipe works 2. inlined String parsers into #getTypeParser
1 parent 68c060d commit 48ec2ad

File tree

11 files changed

+378
-301
lines changed

11 files changed

+378
-301
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: 53 additions & 6 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,9 +9,47 @@ 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.HomeKey
32+
import com.example.nav3recipes.deeplink.basic.ui.UsersKey
1233

1334
/**
14-
* This activity allows the user to create a deep link and make a request with it
35+
* This activity allows the user to create a deep link and make a request with it.
36+
*
37+
* **HOW THIS RECIPE WORKS** it consists of two activities - [CreateDeepLinkActivity] to construct
38+
* and trigger the deeplink request, and the [MainActivity] to show how an app can handle
39+
* that request.
40+
*
41+
* **DEMONSTRATED FORMS OF DEEPLINK** The [MainActivity] has a several backStack keys to
42+
* demonstrate different types of supported deeplinks:
43+
* 1. [HomeKey] - deeplink with an exact url (no deeplink arguments)
44+
* 2. [UsersKey] - deeplink with path arguments
45+
* 3. [SearchKey] - deeplink with query arguments
46+
* See [MainActivity.deepLinkPatterns] for the actual url pattern of each.
47+
*
48+
* **RECIPE STRUCTURE** This recipe consists of three main packages:
49+
* 1. basic.deeplink - Contains the two activities
50+
* 2. basic.deeplink.ui - Contains the activity UI code, i.e. Screens, global string variables etc
51+
* 3. basic.deeplink.deeplinkutil - Contains the classes and helper methods to parse and match
52+
* the deeplinks
1553
*
1654
* See [MainActivity] for how the requested deeplink is handled.
1755
*/
@@ -24,7 +62,7 @@ class CreateDeepLinkActivity : ComponentActivity() {
2462
* UI for deeplink sandbox
2563
*/
2664
EntryScreen("Sandbox - Build Your Deeplink") {
27-
TextContent("Base url:\n$PATH_BASE/")
65+
TextContent("Base url:\n${PATH_BASE}/")
2866
var showFilterOptions by remember { mutableStateOf(false) }
2967
val selectedPath = remember { mutableStateOf(MENU_OPTIONS_PATH[KEY_PATH]?.first()) }
3068

@@ -42,10 +80,12 @@ class CreateDeepLinkActivity : ComponentActivity() {
4280
showQueryOptions = true
4381
showFilterOptions = false
4482
}
83+
4584
PATH_INCLUDE -> {
4685
showQueryOptions = false
4786
showFilterOptions = true
4887
}
88+
4989
else -> {
5090
showQueryOptions = false
5191
showFilterOptions = false
@@ -64,7 +104,7 @@ class CreateDeepLinkActivity : ComponentActivity() {
64104
if (showFilterOptions) {
65105
MenuDropDown(
66106
menuOptions = MENU_OPTIONS_FILTER,
67-
) { _, selected ->
107+
) { _, selected ->
68108
selectedFilter = selected
69109
}
70110
}
@@ -104,9 +144,10 @@ class CreateDeepLinkActivity : ComponentActivity() {
104144
}
105145
}
106146
}
147+
107148
else -> ""
108149
}
109-
val finalUrl = "$PATH_BASE/${selectedPath.value}$arguments"
150+
val finalUrl = "${PATH_BASE}/${selectedPath.value}$arguments"
110151
TextContent("Final url:\n$finalUrl")
111152
// deeplink to target
112153
DeepLinkButton(
@@ -133,7 +174,13 @@ private val MENU_OPTIONS_FILTER = mapOf(
133174
)
134175

135176
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),
177+
SearchKey::firstName.name to listOf(
178+
EMPTY,
179+
FIRST_NAME_JOHN,
180+
FIRST_NAME_TOM,
181+
FIRST_NAME_MARY,
182+
FIRST_NAME_JULIE
183+
),
137184
SearchKey::location.name to listOf(EMPTY, LOCATION_CA, LOCATION_BC, LOCATION_BR, LOCATION_US)
138185
)
139186

Lines changed: 24 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,8 @@ 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+
// internal so that landing activity can link to this in the kdocs
56+
internal val deepLinkPatterns: List<DeepLinkPattern<out NavKey>> = listOf(
4157
// "https://www.nav3recipes.com/home"
4258
DeepLinkPattern(HomeKey.serializer(), (URL_HOME_EXACT).toUri()),
4359
// "https://www.nav3recipes.com/users/with/{filter}"
@@ -54,10 +70,10 @@ class MainActivity : ComponentActivity() {
5470
// associate the target with the correct backstack key
5571
val key: NavKey = uri?.let {
5672
/** STEP 2. Parse requested deeplink */
57-
val target = DeepLinkRequest(uri)
73+
val request = DeepLinkRequest(uri)
5874
/** STEP 3. Compared requested with supported deeplink to find match*/
59-
val match = deepLinkPatterns.firstNotNullOfOrNull { candidate ->
60-
target.match(candidate)
75+
val match = deepLinkPatterns.firstNotNullOfOrNull { pattern ->
76+
DeepLinkMatcher(request, pattern).match()
6177
}
6278
/** STEP 4. If match is found, associate match to the correct key*/
6379
match?.let {
@@ -98,9 +114,9 @@ class MainActivity : ComponentActivity() {
98114
TextContent("<matches query parameters, if any>")
99115
val matchingUsers = LIST_USERS.filter { user ->
100116
(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)
117+
(search.location == null || user.location == search.location) &&
118+
(search.ageMin == null || user.age >= search.ageMin) &&
119+
(search.ageMax == null || user.age <= search.ageMax)
104120
}
105121
FriendsList(matchingUsers)
106122
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
)
79+
80+
const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"

0 commit comments

Comments
 (0)