Skip to content

Commit 47eef5d

Browse files
committed
WIP deeplink updates
1 parent 403b89e commit 47eef5d

8 files changed

Lines changed: 352 additions & 28 deletions

File tree

JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,28 +23,26 @@ import androidx.compose.material3.ModalNavigationDrawer
2323
import androidx.compose.material3.rememberDrawerState
2424
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
2525
import androidx.compose.runtime.Composable
26-
import androidx.compose.runtime.remember
2726
import androidx.compose.runtime.rememberCoroutineScope
2827
import androidx.navigation3.runtime.NavKey
2928
import com.example.jetnews.data.AppContainer
3029
import com.example.jetnews.ui.components.AppNavRail
3130
import com.example.jetnews.ui.navigation.HomeKey
3231
import com.example.jetnews.ui.navigation.JetnewsNavDisplay
33-
import com.example.jetnews.ui.navigation.NavigationState
3432
import com.example.jetnews.ui.navigation.Navigator
3533
import com.example.jetnews.ui.navigation.rememberNavigationState
3634
import com.example.jetnews.ui.theme.JetnewsTheme
3735
import kotlinx.coroutines.launch
3836

3937
@Composable
40-
fun JetnewsApp(appContainer: AppContainer, widthSizeClass: WindowWidthSizeClass, deeplinkKey: HomeKey?) {
38+
fun JetnewsApp(appContainer: AppContainer, widthSizeClass: WindowWidthSizeClass, deepLinkKey: NavKey?) {
4139
JetnewsTheme {
4240
val coroutineScope = rememberCoroutineScope()
4341

4442
val isExpandedScreen = widthSizeClass == WindowWidthSizeClass.Expanded
4543
val sizeAwareDrawerState = rememberSizeAwareDrawerState(isExpandedScreen)
4644

47-
val navigationState = rememberNavigationState(deeplinkKey ?: HomeKey())
45+
val navigationState = rememberNavigationState(deepLinkKey ?: HomeKey())
4846
val navigator = Navigator(navigationState)
4947

5048
ModalNavigationDrawer(

JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,14 @@
1616

1717
package com.example.jetnews.ui
1818

19-
import android.content.Intent
20-
import android.net.Uri
2119
import android.os.Bundle
2220
import androidx.activity.ComponentActivity
2321
import androidx.activity.compose.setContent
2422
import androidx.activity.enableEdgeToEdge
2523
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
2624
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
27-
import androidx.navigation3.runtime.NavKey
2825
import com.example.jetnews.JetnewsApplication
29-
import com.example.jetnews.ui.navigation.HomeKey
30-
import com.example.jetnews.ui.navigation.POST_ID
26+
import com.example.jetnews.ui.navigation.toNavKey
3127

3228
class MainActivity : ComponentActivity() {
3329

@@ -39,18 +35,12 @@ class MainActivity : ComponentActivity() {
3935
val appContainer = (application as JetnewsApplication).container
4036
setContent {
4137
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
42-
JetnewsApp(appContainer, widthSizeClass, getDeepLinkKey(intent))
38+
39+
JetnewsApp(
40+
appContainer = appContainer,
41+
widthSizeClass = widthSizeClass,
42+
deepLinkKey = intent.toNavKey()
43+
)
4344
}
4445
}
4546
}
46-
47-
private fun getDeepLinkKey(intent: Intent): HomeKey? {
48-
val uri: Uri = intent.data ?: return null
49-
val pathParams = uri.pathSegments
50-
if (pathParams.lastOrNull() != "home") return null
51-
52-
val queryParams = uri.getQueryParameters(POST_ID)
53-
if (queryParams.isEmpty() || queryParams.size > 1) return null
54-
// "https://developer.android.com/jetnews/home?postId={$POST_ID}"
55-
return HomeKey(postId = Uri.decode(queryParams.firstOrNull()))
56-
}

JetNews/app/src/main/java/com/example/jetnews/ui/navigation/JetnewsNavDisplay.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ import com.example.jetnews.ui.home.HomeViewModel
3131
import com.example.jetnews.ui.interests.InterestsRoute
3232
import com.example.jetnews.ui.interests.InterestsViewModel
3333

34-
const val POST_ID = "postId"
35-
3634
@Composable
3735
fun JetnewsNavDisplay(
3836
navigationState: NavigationState,

JetNews/app/src/main/java/com/example/jetnews/ui/navigation/JetnewsNavigation.kt

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,47 @@
1616

1717
package com.example.jetnews.ui.navigation
1818

19+
import android.content.Intent
20+
import android.net.Uri
1921
import androidx.compose.runtime.Composable
2022
import androidx.compose.runtime.remember
2123
import androidx.compose.runtime.snapshots.SnapshotStateList
2224
import androidx.compose.runtime.toMutableStateList
25+
import androidx.core.net.toUri
2326
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
2427
import androidx.navigation3.runtime.NavBackStack
2528
import androidx.navigation3.runtime.NavEntry
2629
import androidx.navigation3.runtime.NavKey
2730
import androidx.navigation3.runtime.rememberDecoratedNavEntries
2831
import androidx.navigation3.runtime.rememberNavBackStack
2932
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
33+
import com.example.jetnews.ui.navigation.deeplinks.DeepLinkMatcher
34+
import com.example.jetnews.ui.navigation.deeplinks.DeepLinkPattern
35+
import com.example.jetnews.ui.navigation.deeplinks.KeyDecoder
3036
import kotlinx.serialization.Serializable
3137

38+
interface DeepLinkable {
39+
val deepLinkPattern: DeepLinkPattern<out NavKey>
40+
}
41+
3242
@Serializable
33-
data class HomeKey(val postId: String? = null) : NavKey
43+
data class HomeKey(val postId: String? = null) : NavKey {
44+
companion object : DeepLinkable {
45+
46+
override val deepLinkPattern = DeepLinkPattern(
47+
serializer = serializer(),
48+
uriPattern = "https://developer.android.com/jetnews/home?postId={postId}".toUri()
49+
)
50+
}
51+
}
3452

3553
@Serializable
3654
object InterestsKey : NavKey
3755

38-
fun getNavigationKeys(startKey: HomeKey) = listOf(startKey, InterestsKey)
56+
fun getNavigationKeys(startKey: NavKey) = listOf(startKey, InterestsKey)
3957

4058
@Composable
41-
fun rememberNavigationState(startKey: HomeKey): NavigationState {
59+
fun rememberNavigationState(startKey: NavKey): NavigationState {
4260
val currentKeys = rememberNavBackStack(startKey)
4361
val backStacks = buildMap {
4462
getNavigationKeys(startKey)
@@ -56,8 +74,6 @@ class NavigationState(val currentKeys: NavBackStack<NavKey>, val backStacks: Mut
5674

5775
val currentKey
5876
get() = currentKeys.last()
59-
val currentBackStack
60-
get() = backStacks[currentKey::class.toString()]
6177

6278
@Composable
6379
fun getEntries(entryProvider: (NavKey) -> NavEntry<NavKey>): SnapshotStateList<NavEntry<NavKey>> {
@@ -104,3 +120,23 @@ class Navigator(val state: NavigationState) {
104120
state.currentKeys.removeLastOrNull()
105121
}
106122
}
123+
124+
private val deepLinkPatterns = listOf(HomeKey.deepLinkPattern)
125+
126+
/**
127+
* Convert an `Intent` into a `NavKey`.
128+
*
129+
* @return the `NavKey` or null if conversion was not possible.
130+
*/
131+
fun Intent.toNavKey(): NavKey? {
132+
133+
val uri: Uri = this.data ?: return null
134+
135+
val match = deepLinkPatterns.firstNotNullOfOrNull { pattern ->
136+
DeepLinkMatcher(uri, pattern).match()
137+
}
138+
139+
return match?.let {
140+
KeyDecoder(match.args).decodeSerializableValue(match.serializer)
141+
}
142+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.example.jetnews.ui.navigation.deeplinks
2+
3+
import android.net.Uri
4+
import android.util.Log
5+
import androidx.navigation3.runtime.NavKey
6+
import kotlinx.serialization.KSerializer
7+
8+
internal class DeepLinkMatcher<T : NavKey>(
9+
val data: Uri,
10+
val pattern: DeepLinkPattern<T>
11+
) {
12+
/**
13+
* Match a [DeepLinkRequest] to a [DeepLinkPattern].
14+
*
15+
* Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
16+
*/
17+
fun match(): DeepLinkMatchResult<T>? {
18+
if (data.scheme != pattern.uriPattern.scheme) return null
19+
if (!data.authority.equals(pattern.uriPattern.authority, ignoreCase = true)) return null
20+
if (data.pathSegments.size != pattern.pathSegments.size) return null
21+
// exact match (url does not contain any arguments)
22+
if (data == pattern.uriPattern)
23+
return DeepLinkMatchResult(pattern.serializer, mapOf())
24+
25+
// map of query names to their instantiated objects
26+
val args = mutableMapOf<String, Any>()
27+
28+
// match the path
29+
data.pathSegments
30+
.asSequence()
31+
// zip to compare the two objects side by side, order matters here so we
32+
// need to make sure the compared segments are at the same position within the url
33+
.zip(pattern.pathSegments.asSequence())
34+
.forEach { it ->
35+
// retrieve the two path segments to compare
36+
val requestedSegment = it.first
37+
val candidateSegment = it.second
38+
// if the potential match expects a path arg for this segment, try to parse the
39+
// requested segment into the expected type
40+
if (candidateSegment.isArgument) {
41+
val parsedValue = try {
42+
candidateSegment.parser.invoke(requestedSegment)
43+
} catch (e: IllegalArgumentException) {
44+
Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e)
45+
return null
46+
}
47+
args[candidateSegment.name] = parsedValue
48+
} else if(requestedSegment != candidateSegment.name){
49+
// if it's path arg is not the expected type, its not a match
50+
return null
51+
}
52+
}
53+
// match queries (if any)
54+
data.queryParametersMap.forEach { query ->
55+
val queryName = query.key
56+
val queryValue = query.value
57+
val queryStringParser = pattern.queryValueParsers[queryName] ?: error("Could not get parser for query with name: $queryName")
58+
if (queryValue == null){
59+
error("Query value: $queryValue was null")
60+
} else {
61+
val queryParsedValue = queryStringParser.invoke(queryValue)
62+
args[queryName] = queryParsedValue
63+
}
64+
}
65+
// provide the serializer of the matching key and map of arg names to parsed arg values
66+
return DeepLinkMatchResult(pattern.serializer, args)
67+
}
68+
}
69+
70+
71+
/**
72+
* Created when a requested deeplink matches with a supported deeplink
73+
*
74+
* @param [T] the backstack key associated with the deeplink that matched with the requested deeplink
75+
* @param serializer serializer for [T]
76+
* @param args The map of argument name to argument value. The value is expected to have already
77+
* been parsed from the raw url string back into its proper KType as declared in [T].
78+
* Includes arguments for all parts of the uri - path, query, etc.
79+
* */
80+
internal data class DeepLinkMatchResult<T : NavKey>(
81+
val serializer: KSerializer<T>,
82+
val args: Map<String, Any>
83+
)
84+
85+
const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.example.jetnews.ui.navigation.deeplinks
2+
3+
import android.net.Uri
4+
import androidx.navigation3.runtime.NavKey
5+
import kotlinx.serialization.KSerializer
6+
import kotlinx.serialization.descriptors.PrimitiveKind
7+
import kotlinx.serialization.descriptors.SerialKind
8+
import kotlinx.serialization.encoding.CompositeDecoder
9+
import java.io.Serializable
10+
11+
/**
12+
* Parse a URI and store its metadata in an easily readable format
13+
*
14+
* The supported deeplink is expected to be built from a serializable backstack key [T] that
15+
* supports deeplink. This means that if this deeplink contains any arguments (path or query),
16+
* the argument name must match any of [T] member field name.
17+
*
18+
* One [DeepLinkPattern] should be created for each supported deeplink. This means if [T]
19+
* supports two deeplink patterns:
20+
* ```
21+
* val deeplink1 = www.nav3recipes.com/home
22+
* val deeplink2 = www.nav3recipes.com/profile/{userId}
23+
* ```
24+
* Then two [DeepLinkPattern] should be created
25+
* ```
26+
* val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1)
27+
* val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2)
28+
* ```
29+
*
30+
* This implementation assumes a few things:
31+
* 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match
32+
* 2. all query arguments are optional by way of nullable/has default value
33+
*
34+
* @param T the backstack key type that supports the deeplinking of [uriPattern]
35+
* @param serializer the serializer of [T]
36+
* @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}"
37+
*/
38+
data class DeepLinkPattern<T : NavKey>(
39+
val serializer: KSerializer<T>,
40+
val uriPattern: Uri,
41+
) {
42+
43+
// The pattern for arguments is {argument_name}
44+
private val argumentPattern = Regex("\\{(.+?)\\}")
45+
46+
// TODO make these lazy
47+
// TODO donturner - Lazy instantiation of a malformed deeplink URI will cause the app to crash. Better to instantiate here and have the app
48+
// crash on startup.
49+
/**
50+
* Parse the URI path into a list of [PathSegment]s
51+
*
52+
* order matters here - path segments need to match in value and order when matching
53+
* requested deeplink to supported deeplink
54+
*/
55+
val pathSegments: List<PathSegment> = uriPattern.pathSegments.map { segment ->
56+
val pathArgument = argumentPattern.find(segment)
57+
if (pathArgument != null) {
58+
val argumentName = pathArgument.groups[1]?.value ?: error("Could not extract argument name from segment: $segment")
59+
val elementIndex = serializer.descriptor.getElementIndex(argumentName)
60+
if (elementIndex == CompositeDecoder.UNKNOWN_NAME) {
61+
error("Path parameter '$argumentName' defined in the DeepLink $uriPattern does not exist in the Serializable class " +
62+
"'${serializer.descriptor.serialName}'.")
63+
}
64+
val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
65+
PathSegment(argumentName, true, getTypeParser(elementDescriptor.kind))
66+
} else {
67+
PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING))
68+
}
69+
}
70+
71+
/**
72+
* Parse supported queries into a map of queryParameterNames to [TypeParser]
73+
*
74+
* This will be used later on to parse a provided query value into the correct KType
75+
*/
76+
val queryValueParsers: Map<String, TypeParser> =
77+
uriPattern.queryParameterNames.associateWith { paramName ->
78+
val elementIndex = serializer.descriptor.getElementIndex(paramName)
79+
if (elementIndex == CompositeDecoder.UNKNOWN_NAME) {
80+
throw IllegalArgumentException(
81+
"Query parameter '$paramName' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'.",
82+
)
83+
}
84+
val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
85+
getTypeParser(elementDescriptor.kind)
86+
}
87+
88+
/**
89+
* A segment of a deeplink URI.
90+
*
91+
* @param name - The name of the segment.
92+
* @param isArgument - `true` if this segment is an argument.
93+
* @param parser - the `TypeParser` for this segment.
94+
*/
95+
class PathSegment(
96+
val name: String,
97+
val isArgument: Boolean,
98+
val parser: TypeParser,
99+
)
100+
}
101+
102+
/**
103+
* Parses a String into a Serializable Primitive
104+
*/
105+
private typealias TypeParser = (String) -> Serializable
106+
107+
private fun getTypeParser(kind: SerialKind): TypeParser {
108+
return when (kind) {
109+
PrimitiveKind.STRING -> Any::toString
110+
PrimitiveKind.INT -> String::toInt
111+
PrimitiveKind.BOOLEAN -> String::toBoolean
112+
PrimitiveKind.BYTE -> String::toByte
113+
PrimitiveKind.CHAR -> String::toCharArray
114+
PrimitiveKind.DOUBLE -> String::toDouble
115+
PrimitiveKind.FLOAT -> String::toFloat
116+
PrimitiveKind.LONG -> String::toLong
117+
PrimitiveKind.SHORT -> String::toShort
118+
else -> throw IllegalArgumentException(
119+
"Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive.",
120+
)
121+
}
122+
}

0 commit comments

Comments
 (0)