Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions JetNews/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose)
}

Expand Down Expand Up @@ -92,10 +93,12 @@ dependencies {

implementation(libs.kotlin.stdlib)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.core)

implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.materialWindow)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.compose.ui.tooling.preview)
Expand All @@ -116,7 +119,10 @@ dependencies {
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.viewModelCompose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.window)

androidTestImplementation(libs.junit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.printToString
import androidx.compose.ui.test.performScrollTo
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.jetnews.data.posts.impl.manuel
Expand Down Expand Up @@ -55,17 +54,10 @@ class JetnewsTests {

@Test
fun app_opensArticle() {

println(composeTestRule.onRoot().printToString())
composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0].performClick()

println(composeTestRule.onRoot().printToString())
try {
composeTestRule.onAllNodes(hasText("3 min read", substring = true))[0].assertExists()
Comment thread
bsagmoe marked this conversation as resolved.
} catch (e: AssertionError) {
println(composeTestRule.onRoot().printToString())
throw e
}
composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0]
.performScrollTo()
.performClick()
composeTestRule.waitUntilExactlyOneExists(hasText("Use Dagger in Kotlin!", substring = true), 5000L)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
package com.example.jetnews

import android.content.Context
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import com.example.jetnews.ui.JetnewsApp
import com.example.jetnews.ui.home.HomeKey

/**
* Launches the app from a test context
Expand All @@ -28,7 +28,8 @@ fun ComposeContentTestRule.launchJetNewsApp(context: Context) {
setContent {
JetnewsApp(
appContainer = TestAppContainer(context),
widthSizeClass = WindowWidthSizeClass.Compact,
isOpenedByDeepLink = false,
initialBackStack = listOf(HomeKey),
)
}
}
2 changes: 2 additions & 0 deletions JetNews/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
Expand All @@ -35,6 +36,7 @@
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="developer.android.com"
android:pathPrefix="/jetnews"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.jetnews.deeplink

import android.net.Uri
import androidx.core.net.toUri
import androidx.navigation3.runtime.NavKey
import com.example.jetnews.deeplink.util.DeepLinkMatcher
import com.example.jetnews.deeplink.util.DeepLinkPattern
import com.example.jetnews.deeplink.util.DeepLinkRequest
import com.example.jetnews.deeplink.util.KeyDecoder
import com.example.jetnews.ui.home.HomeKey
import com.example.jetnews.ui.interests.InterestsKey
import com.example.jetnews.ui.navigation.DeepLinkKey
import com.example.jetnews.ui.post.PostKey

val HomeDeepLinkPattern = DeepLinkPattern(
Comment thread
bsagmoe marked this conversation as resolved.
Outdated
HomeKey.serializer(),
uriPattern = "https://developer.android.com/jetnews".toUri(),
)

val PostDeepLinkPattern = DeepLinkPattern(
PostKey.serializer(),
uriPattern = "https://developer.android.com/jetnews/posts/{postId}".toUri(),
)

val InterestsDeepLinkPattern = DeepLinkPattern(
InterestsKey.serializer(),
uriPattern = "https://developer.android.com/jetnews/interests".toUri(),
)

val JetnewsDeepLinkPatterns = listOf(HomeDeepLinkPattern, PostDeepLinkPattern, InterestsDeepLinkPattern)

fun Uri.handleDeepLink(): List<NavKey>? {
val deepLinkRequest = DeepLinkRequest(this)

val deepLinkMatchResult = JetnewsDeepLinkPatterns.firstNotNullOfOrNull {
DeepLinkMatcher(deepLinkRequest, it).match()
} ?: return null

val initialKey = KeyDecoder(deepLinkMatchResult.args).decodeSerializableValue(deepLinkMatchResult.serializer)

return generateSequence(initialKey) { (it as? DeepLinkKey)?.parent }
.toList()
.asReversed()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright 2026 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.example.jetnews.deeplink.util

import android.util.Log
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.KSerializer

internal class DeepLinkMatcher<T : NavKey>(val request: DeepLinkRequest, val deepLinkPattern: DeepLinkPattern<T>) {
/**
* Match a [DeepLinkRequest] to a [DeepLinkPattern].
*
* Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise
*/
fun match(): DeepLinkMatchResult<T>? {
if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null
if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null
if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null
// exact match (url does not contain any arguments)
if (request.uri == deepLinkPattern.uriPattern)
return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf())

val args = mutableMapOf<String, Any>()
// match the path
request.pathSegments
.asSequence()
// zip to compare the two objects side by side, order matters here so we
// need to make sure the compared segments are at the same position within the url
.zip(deepLinkPattern.pathSegments.asSequence())
.forEach { it ->
// retrieve the two path segments to compare
val requestedSegment = it.first
val candidateSegment = it.second
// if the potential match expects a path arg for this segment, try to parse the
// requested segment into the expected type
if (candidateSegment.isParamArg) {
val parsedValue = try {
candidateSegment.typeParser.invoke(requestedSegment)
} catch (e: IllegalArgumentException) {
Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e)
return null
}
args[candidateSegment.stringValue] = parsedValue
} else if (requestedSegment != candidateSegment.stringValue) {
// if it's path arg is not the expected type, its not a match
return null
}
}
// match queries (if any)
request.queries.forEach { query ->
val name = query.key
// If the pattern does not define this query parameter, ignore it.
// This prevents a NullPointerException.
val queryStringParser = deepLinkPattern.queryValueParsers[name] ?: return@forEach

val queryParsedValue = try {
queryStringParser.invoke(query.value)
} catch (e: IllegalArgumentException) {
Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e)
return null
}
args[name] = queryParsedValue
}
// provide the serializer of the matching key and map of arg names to parsed arg values
return DeepLinkMatchResult(deepLinkPattern.serializer, args)
}
}

/**
* Created when a requested deeplink matches with a supported deeplink
*
* @param [T] the backstack key associated with the deeplink that matched with the requested deeplink
* @param serializer serializer for [T]
* @param args The map of argument name to argument value. The value is expected to have already
* been parsed from the raw url string back into its proper KType as declared in [T].
* Includes arguments for all parts of the uri - path, query, etc.
* */
internal data class DeepLinkMatchResult<T : NavKey>(val serializer: KSerializer<T>, val args: Map<String, Any>)

const val TAG_LOG_ERROR = "Nav3RecipesDeepLink"
Loading
Loading