Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
37 changes: 14 additions & 23 deletions JetNews/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,28 @@ project from Android Studio following the steps

## Features

This sample contains three screens: a list of articles, a detail page for articles, and a page to
subscribe to topics of interest. The navigation from the the list of articles to the interests
This sample contains three screens: a list of posts, a detail page for a post, and a page to
subscribe to topics of interest. The navigation from the list of posts to the interests
screen uses a navigation drawer.

### App scaffolding

Package [`com.example.jetnews.ui`][1]

[`JetnewsApp.kt`][2] arranges the different screens in the `NavDrawerLayout`.
[`JetnewsApp.kt`][2] sets up the app's navigation state and the modal drawer used for navigation
on smaller windows.

[`JetnewsNavGraph.kt`][3] configures the navigation routes and actions in the app.
[`JetnewsNavDisplay.kt`][3] displays the primary content of the app: the list of posts, the
posts themselves, and the interests page. It uses a list-detail scene strategy to adaptively
display more or less content depending on the window size.

<img src="screenshots/jetnews_all_screens.png" alt="Screenshot">

[1]: app/src/main/java/com/example/jetnews/ui
[2]: app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt
[3]: app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt
[3]: app/src/main/java/com/example/jetnews/ui/JetnewsNavDisplay.kt

### Main article list
### Main post list

Package [`com.example.jetnews.ui.home`][4]

Expand All @@ -47,14 +52,14 @@ See how to:

[4]: app/src/main/java/com/example/jetnews/ui/home

### Article detail
### Post detail

Package [`com.example.jetnews.ui.article`][5]
Package [`com.example.jetnews.ui.post`][5]

This screen dives into the Text API, showing how to use different fonts than the ones defined in
[`Typography`][6]. It also adds a bottom app bar, with custom actions.

[5]: app/src/main/java/com/example/jetnews/ui/article
[5]: app/src/main/java/com/example/jetnews/ui/post
[6]: app/src/main/java/com/example/jetnews/ui/theme/Type.kt

### Interests screen
Expand Down Expand Up @@ -99,20 +104,6 @@ UI tests can be run on device/emulators or on JVM with Robolectric.
* To run Instrumented tests use the "Instrumented tests" run configuration or run the `./gradlew connectedCheck` command.
* To run tests with Robolectric use the "Robolectric tests" run configuration or run the `./gradlew testDebug` command.

## Jetnews for every screen

<img src="screenshots/jetnews_all_screens.png" alt="Screenshot">

We recently updated Jetnews to enhance its behavior across all mobile devices, both big and small.
Jetnews already had support for “traditional” mobile screens, so it was tempting to describe all of
our changes as “adding large screen support.” While that is true, it misses the point of having
adaptive UI. For example, if your app is running in split screen mode on a tablet, it shouldn't try
to display “tablet UI” unless it actually has enough space for it. With all of these changes,
Jetnews is working better than ever on large screens, but also on small screens too.

Check out the blog post that explains all the changes in more details:
https://medium.com/androiddevelopers/jetnews-for-every-screen-4d8e7927752

## License

```
Expand Down
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 @@ -54,18 +53,11 @@ 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()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This text also shows up in the list screen, so the test didn't actually test that the article screen was opened to the correct article.

} catch (e: AssertionError) {
println(composeTestRule.onRoot().printToString())
throw e
}
fun app_opensPost() {
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(
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