Skip to content

Commit e8d5f4f

Browse files
committed
Migrate Jetnews to Navigation 3
1 parent 1bf1c20 commit e8d5f4f

29 files changed

Lines changed: 1376 additions & 593 deletions

JetNews/app/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
1919

2020
plugins {
2121
alias(libs.plugins.android.application)
22+
alias(libs.plugins.kotlin.serialization)
2223
alias(libs.plugins.compose)
2324
}
2425

@@ -92,10 +93,12 @@ dependencies {
9293

9394
implementation(libs.kotlin.stdlib)
9495
implementation(libs.kotlinx.coroutines.android)
96+
implementation(libs.kotlinx.serialization.core)
9597

9698
implementation(libs.androidx.compose.animation)
9799
implementation(libs.androidx.compose.foundation.layout)
98100
implementation(libs.androidx.compose.material3)
101+
implementation(libs.androidx.compose.material3.adaptive)
99102
implementation(libs.androidx.compose.materialWindow)
100103
implementation(libs.androidx.compose.runtime.livedata)
101104
implementation(libs.androidx.compose.ui.tooling.preview)
@@ -116,7 +119,10 @@ dependencies {
116119
implementation(libs.androidx.lifecycle.livedata.ktx)
117120
implementation(libs.androidx.lifecycle.viewModelCompose)
118121
implementation(libs.androidx.lifecycle.runtime.compose)
122+
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
119123
implementation(libs.androidx.navigation.compose)
124+
implementation(libs.androidx.navigation3.runtime)
125+
implementation(libs.androidx.navigation3.ui)
120126
implementation(libs.androidx.window)
121127

122128
androidTestImplementation(libs.junit)

JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class JetnewsTests {
6161

6262
println(composeTestRule.onRoot().printToString())
6363
try {
64-
composeTestRule.onAllNodes(hasText("3 min read", substring = true))[0].assertExists()
64+
composeTestRule.onAllNodes(hasText("It provides fully static", substring = true))[0].assertExists()
6565
} catch (e: AssertionError) {
6666
println(composeTestRule.onRoot().printToString())
6767
throw e

JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
package com.example.jetnews
1818

1919
import android.content.Context
20-
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
2120
import androidx.compose.ui.test.junit4.ComposeContentTestRule
2221
import com.example.jetnews.ui.JetnewsApp
22+
import com.example.jetnews.ui.home.HomeKey
2323

2424
/**
2525
* Launches the app from a test context
@@ -28,7 +28,8 @@ fun ComposeContentTestRule.launchJetNewsApp(context: Context) {
2828
setContent {
2929
JetnewsApp(
3030
appContainer = TestAppContainer(context),
31-
widthSizeClass = WindowWidthSizeClass.Compact,
31+
isOpenedByDeepLink = false,
32+
initialBackStack = listOf(HomeKey),
3233
)
3334
}
3435
}

JetNews/app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
<activity
2828
android:name=".ui.MainActivity"
2929
android:exported="true"
30+
android:launchMode="singleTop"
3031
android:windowSoftInputMode="adjustResize">
3132
<intent-filter>
3233
<action android:name="android.intent.action.MAIN" />
@@ -35,6 +36,7 @@
3536
<intent-filter>
3637
<action android:name="android.intent.action.VIEW" />
3738
<category android:name="android.intent.category.BROWSABLE"/>
39+
<category android:name="android.intent.category.DEFAULT" />
3840
<data
3941
android:host="developer.android.com"
4042
android:pathPrefix="/jetnews"
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetnews.deeplink
18+
19+
import android.net.Uri
20+
import androidx.core.net.toUri
21+
import androidx.navigation3.runtime.NavKey
22+
import com.example.jetnews.deeplink.util.DeepLinkMatcher
23+
import com.example.jetnews.deeplink.util.DeepLinkPattern
24+
import com.example.jetnews.deeplink.util.DeepLinkRequest
25+
import com.example.jetnews.deeplink.util.KeyDecoder
26+
import com.example.jetnews.ui.home.HomeKey
27+
import com.example.jetnews.ui.interests.InterestsKey
28+
import com.example.jetnews.ui.navigation.DeepLinkKey
29+
import com.example.jetnews.ui.post.PostKey
30+
31+
val HomeDeepLinkPattern = DeepLinkPattern(
32+
HomeKey.serializer(),
33+
uriPattern = "https://developer.android.com/jetnews".toUri(),
34+
)
35+
36+
val PostDeepLinkPattern = DeepLinkPattern(
37+
PostKey.serializer(),
38+
uriPattern = "https://developer.android.com/jetnews/posts/{postId}".toUri(),
39+
)
40+
41+
val InterestsDeepLinkPattern = DeepLinkPattern(
42+
InterestsKey.serializer(),
43+
uriPattern = "https://developer.android.com/jetnews/interests".toUri(),
44+
)
45+
46+
val JetnewsDeepLinkPatterns = listOf(HomeDeepLinkPattern, PostDeepLinkPattern, InterestsDeepLinkPattern)
47+
48+
fun Uri.handleDeepLink(): List<NavKey>? {
49+
val deepLinkRequest = DeepLinkRequest(this)
50+
51+
val deepLinkMatchResult = JetnewsDeepLinkPatterns.firstNotNullOfOrNull {
52+
DeepLinkMatcher(deepLinkRequest, it).match()
53+
} ?: return null
54+
55+
val initialKey = KeyDecoder(deepLinkMatchResult.args).decodeSerializableValue(deepLinkMatchResult.serializer)
56+
57+
return generateSequence(initialKey) { (it as? DeepLinkKey)?.parent }
58+
.toList()
59+
.asReversed()
60+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetnews.deeplink.util
18+
19+
import android.util.Log
20+
import androidx.navigation3.runtime.NavKey
21+
import kotlinx.serialization.KSerializer
22+
23+
internal class DeepLinkMatcher<T : NavKey>(val request: DeepLinkRequest, val deepLinkPattern: DeepLinkPattern<T>) {
24+
/**
25+
* Match a [DeepLinkRequest] to a [DeepLinkPattern].
26+
*
27+
* Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
28+
*/
29+
fun match(): DeepLinkMatchResult<T>? {
30+
if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null
31+
if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null
32+
if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null
33+
// exact match (url does not contain any arguments)
34+
if (request.uri == deepLinkPattern.uriPattern)
35+
return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf())
36+
37+
val args = mutableMapOf<String, Any>()
38+
// match the path
39+
request.pathSegments
40+
.asSequence()
41+
// zip to compare the two objects side by side, order matters here so we
42+
// need to make sure the compared segments are at the same position within the url
43+
.zip(deepLinkPattern.pathSegments.asSequence())
44+
.forEach { it ->
45+
// retrieve the two path segments to compare
46+
val requestedSegment = it.first
47+
val candidateSegment = it.second
48+
// if the potential match expects a path arg for this segment, try to parse the
49+
// requested segment into the expected type
50+
if (candidateSegment.isParamArg) {
51+
val parsedValue = try {
52+
candidateSegment.typeParser.invoke(requestedSegment)
53+
} catch (e: IllegalArgumentException) {
54+
Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e)
55+
return null
56+
}
57+
args[candidateSegment.stringValue] = parsedValue
58+
} else if (requestedSegment != candidateSegment.stringValue) {
59+
// if it's path arg is not the expected type, its not a match
60+
return null
61+
}
62+
}
63+
// match queries (if any)
64+
request.queries.forEach { query ->
65+
val name = query.key
66+
// If the pattern does not define this query parameter, ignore it.
67+
// This prevents a NullPointerException.
68+
val queryStringParser = deepLinkPattern.queryValueParsers[name] ?: return@forEach
69+
70+
val queryParsedValue = try {
71+
queryStringParser.invoke(query.value)
72+
} catch (e: IllegalArgumentException) {
73+
Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e)
74+
return null
75+
}
76+
args[name] = queryParsedValue
77+
}
78+
// provide the serializer of the matching key and map of arg names to parsed arg values
79+
return DeepLinkMatchResult(deepLinkPattern.serializer, args)
80+
}
81+
}
82+
83+
/**
84+
* Created when a requested deeplink matches with a supported deeplink
85+
*
86+
* @param [T] the backstack key associated with the deeplink that matched with the requested deeplink
87+
* @param serializer serializer for [T]
88+
* @param args The map of argument name to argument value. The value is expected to have already
89+
* been parsed from the raw url string back into its proper KType as declared in [T].
90+
* Includes arguments for all parts of the uri - path, query, etc.
91+
* */
92+
internal data class DeepLinkMatchResult<T : NavKey>(val serializer: KSerializer<T>, val args: Map<String, Any>)
93+
94+
const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example.jetnews.deeplink.util
18+
19+
import android.net.Uri
20+
import androidx.navigation3.runtime.NavKey
21+
import java.io.Serializable
22+
import kotlinx.serialization.KSerializer
23+
import kotlinx.serialization.descriptors.PrimitiveKind
24+
import kotlinx.serialization.descriptors.SerialKind
25+
import kotlinx.serialization.encoding.CompositeDecoder
26+
27+
/**
28+
* Parse a supported deeplink and stores its metadata as a easily readable format
29+
*
30+
* The following notes applies specifically to this particular sample implementation:
31+
*
32+
* The supported deeplink is expected to be built from a serializable backstack key [T] that
33+
* supports deeplink. This means that if this deeplink contains any arguments (path or query),
34+
* the argument name must match any of [T] member field name.
35+
*
36+
* One [DeepLinkPattern] should be created for each supported deeplink. This means if [T]
37+
* supports two deeplink patterns:
38+
* ```
39+
* val deeplink1 = www.nav3recipes.com/home
40+
* val deeplink2 = www.nav3recipes.com/profile/{userId}
41+
* ```
42+
* Then two [DeepLinkPattern] should be created
43+
* ```
44+
* val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1)
45+
* val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2)
46+
* ```
47+
*
48+
* This implementation assumes a few things:
49+
* 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match
50+
* 2. all query arguments are optional by way of nullable/has default value
51+
*
52+
* @param T the backstack key type that supports the deeplinking of [uriPattern]
53+
* @param serializer the serializer of [T]
54+
* @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}"
55+
*/
56+
class DeepLinkPattern<T : NavKey>(val serializer: KSerializer<T>, val uriPattern: Uri) {
57+
/**
58+
* Help differentiate if a path segment is an argument or a static value
59+
*/
60+
private val regexPatternFillIn = Regex("\\{(.+?)\\}")
61+
62+
// TODO make these lazy
63+
/**
64+
* parse the path into a list of [PathSegment]
65+
*
66+
* order matters here - path segments need to match in value and order when matching
67+
* requested deeplink to supported deeplink
68+
*/
69+
val pathSegments: List<PathSegment> = buildList {
70+
uriPattern.pathSegments.forEach { segment ->
71+
// first, check if it is a path arg
72+
var result = regexPatternFillIn.find(segment)
73+
if (result != null) {
74+
// if so, extract the path arg name (the string value within the curly braces)
75+
val argName = result.groups[1]!!.value
76+
// from [T], read the primitive type of this argument to get the correct type parser
77+
val elementIndex = serializer.descriptor.getElementIndex(argName)
78+
if (elementIndex == CompositeDecoder.UNKNOWN_NAME) {
79+
throw IllegalArgumentException(
80+
"Path parameter '{$argName}' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'.",
81+
)
82+
}
83+
84+
val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
85+
// finally, add the arg name and its respective type parser to the map
86+
add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind)))
87+
} else {
88+
// if its not a path arg, then its just a static string path segment
89+
add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING)))
90+
}
91+
}
92+
}
93+
94+
/**
95+
* Parse supported queries into a map of queryParameterNames to [TypeParser]
96+
*
97+
* This will be used later on to parse a provided query value into the correct KType
98+
*/
99+
val queryValueParsers: Map<String, TypeParser> = buildMap {
100+
uriPattern.queryParameterNames.forEach { paramName ->
101+
val elementIndex = serializer.descriptor.getElementIndex(paramName)
102+
// Ignore static query parameters that are not in the Serializable class
103+
if (elementIndex != CompositeDecoder.UNKNOWN_NAME) {
104+
val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex)
105+
this[paramName] = getTypeParser(elementDescriptor.kind)
106+
}
107+
}
108+
}
109+
110+
/**
111+
* Metadata about a supported path segment
112+
*/
113+
class PathSegment(val stringValue: String, val isParamArg: Boolean, val typeParser: TypeParser)
114+
}
115+
116+
/**
117+
* Parses a String into a Serializable Primitive
118+
*/
119+
private typealias TypeParser = (String) -> Serializable
120+
121+
private fun getTypeParser(kind: SerialKind): TypeParser {
122+
return when (kind) {
123+
PrimitiveKind.STRING -> Any::toString
124+
125+
PrimitiveKind.INT -> String::toInt
126+
127+
PrimitiveKind.BOOLEAN -> String::toBoolean
128+
129+
PrimitiveKind.BYTE -> String::toByte
130+
131+
PrimitiveKind.CHAR -> String::toCharArray
132+
133+
PrimitiveKind.DOUBLE -> String::toDouble
134+
135+
PrimitiveKind.FLOAT -> String::toFloat
136+
137+
PrimitiveKind.LONG -> String::toLong
138+
139+
PrimitiveKind.SHORT -> String::toShort
140+
141+
else -> throw IllegalArgumentException(
142+
"Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive.",
143+
)
144+
}
145+
}

0 commit comments

Comments
 (0)