diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml
index 5bf03b47a..64d22fd5e 100644
--- a/.github/workflows/Build.yaml
+++ b/.github/workflows/Build.yaml
@@ -35,7 +35,7 @@ jobs:
java-version: 17
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true
gradle-home-cache-cleanup: true
@@ -187,7 +187,7 @@ jobs:
java-version: 17
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true
gradle-home-cache-cleanup: true
@@ -222,7 +222,7 @@ jobs:
- name: Display local test coverage (only API 30)
if: matrix.api-level == 30
id: jacoco
- uses: madrapps/jacoco-report@v1.6.1
+ uses: madrapps/jacoco-report@v1.7.0
with:
title: Combined test coverage report
min-coverage-overall: 40
diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml
index b18b41faa..56119f5bd 100644
--- a/.github/workflows/Release.yml
+++ b/.github/workflows/Release.yml
@@ -32,7 +32,7 @@ jobs:
java-version: 17
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v3
+ uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true
gradle-home-cache-cleanup: true
diff --git a/README.md b/README.md
index be1270b16..1f5270323 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,8 @@ Examples:
To run the tests execute the following gradle tasks:
-- `testDemoDebug` run all local tests against the `demoDebug` variant.
+- `testDemoDebug` run all local tests against the `demoDebug` variant. Screenshot tests will fail
+(see below for explanation). To avoid this, run `recordRoborazziDemoDebug` prior to running unit tests.
- `connectedDemoDebugAndroidTest` run all instrumented tests against the `demoDebug` variant.
**Note:** You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute
@@ -137,7 +138,7 @@ stored in `modulename/src/test/screenshots`.
- `compareRoborazziDemoDebug` create comparison images between failed tests and the known correct
images. These can also be found in `modulename/src/test/screenshots`.
-**Note:** The known correct screenshots stored in this repository are recorded on CI using Linux. Other
+**Note on failing screenshot tests:** The known correct screenshots stored in this repository are recorded on CI using Linux. Other
platforms may (and probably will) generate slightly different images, making the screenshot tests fail.
When working on a non-Linux platform, a workaround to this is to run `recordRoborazziDemoDebug` on the
`main` branch before starting work. After making changes, `verifyRoborazziDemoDebug` will identify only
diff --git a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt
index b9135ed42..f95d9ed16 100644
--- a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt
+++ b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt
@@ -12,45 +12,45 @@ androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0
androidx.collection:collection-ktx:1.4.0
androidx.collection:collection:1.4.0
-androidx.compose.animation:animation-android:1.7.0-beta01
-androidx.compose.animation:animation-core-android:1.7.0-beta01
-androidx.compose.animation:animation-core:1.7.0-beta01
-androidx.compose.animation:animation:1.7.0-beta01
-androidx.compose.foundation:foundation-android:1.7.0-beta01
-androidx.compose.foundation:foundation-layout-android:1.7.0-beta01
-androidx.compose.foundation:foundation-layout:1.7.0-beta01
-androidx.compose.foundation:foundation:1.7.0-beta01
-androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01
-androidx.compose.material3.adaptive:adaptive:1.0.0-beta01
-androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01
-androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01
-androidx.compose.material3:material3-android:1.3.0-beta01
-androidx.compose.material3:material3:1.3.0-beta01
-androidx.compose.material:material-icons-core-android:1.6.3
-androidx.compose.material:material-icons-core:1.6.3
-androidx.compose.material:material-icons-extended-android:1.6.3
-androidx.compose.material:material-icons-extended:1.6.3
-androidx.compose.material:material-ripple-android:1.7.0-beta01
-androidx.compose.material:material-ripple:1.7.0-beta01
-androidx.compose.runtime:runtime-android:1.7.0-beta01
-androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01
-androidx.compose.runtime:runtime-saveable:1.7.0-beta01
-androidx.compose.runtime:runtime:1.7.0-beta01
-androidx.compose.ui:ui-android:1.7.0-beta01
-androidx.compose.ui:ui-geometry-android:1.7.0-beta01
-androidx.compose.ui:ui-geometry:1.7.0-beta01
-androidx.compose.ui:ui-graphics-android:1.7.0-beta01
-androidx.compose.ui:ui-graphics:1.7.0-beta01
-androidx.compose.ui:ui-text-android:1.7.0-beta01
-androidx.compose.ui:ui-text:1.7.0-beta01
-androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01
-androidx.compose.ui:ui-tooling-preview:1.7.0-beta01
-androidx.compose.ui:ui-unit-android:1.7.0-beta01
-androidx.compose.ui:ui-unit:1.7.0-beta01
-androidx.compose.ui:ui-util-android:1.7.0-beta01
-androidx.compose.ui:ui-util:1.7.0-beta01
-androidx.compose.ui:ui:1.7.0-beta01
-androidx.compose:compose-bom:2024.02.02
+androidx.compose.animation:animation-android:1.7.0
+androidx.compose.animation:animation-core-android:1.7.0
+androidx.compose.animation:animation-core:1.7.0
+androidx.compose.animation:animation:1.7.0
+androidx.compose.foundation:foundation-android:1.7.0
+androidx.compose.foundation:foundation-layout-android:1.7.0
+androidx.compose.foundation:foundation-layout:1.7.0
+androidx.compose.foundation:foundation:1.7.0
+androidx.compose.material3.adaptive:adaptive-android:1.0.0
+androidx.compose.material3.adaptive:adaptive:1.0.0
+androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0
+androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0
+androidx.compose.material3:material3-android:1.3.0
+androidx.compose.material3:material3:1.3.0
+androidx.compose.material:material-icons-core-android:1.7.0
+androidx.compose.material:material-icons-core:1.7.0
+androidx.compose.material:material-icons-extended-android:1.7.0
+androidx.compose.material:material-icons-extended:1.7.0
+androidx.compose.material:material-ripple-android:1.7.0
+androidx.compose.material:material-ripple:1.7.0
+androidx.compose.runtime:runtime-android:1.7.0
+androidx.compose.runtime:runtime-saveable-android:1.7.0
+androidx.compose.runtime:runtime-saveable:1.7.0
+androidx.compose.runtime:runtime:1.7.0
+androidx.compose.ui:ui-android:1.7.0
+androidx.compose.ui:ui-geometry-android:1.7.0
+androidx.compose.ui:ui-geometry:1.7.0
+androidx.compose.ui:ui-graphics-android:1.7.0
+androidx.compose.ui:ui-graphics:1.7.0
+androidx.compose.ui:ui-text-android:1.7.0
+androidx.compose.ui:ui-text:1.7.0
+androidx.compose.ui:ui-tooling-preview-android:1.7.0
+androidx.compose.ui:ui-tooling-preview:1.7.0
+androidx.compose.ui:ui-unit-android:1.7.0
+androidx.compose.ui:ui-unit:1.7.0
+androidx.compose.ui:ui-util-android:1.7.0
+androidx.compose.ui:ui-util:1.7.0
+androidx.compose.ui:ui:1.7.0
+androidx.compose:compose-bom:2024.09.00
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1
androidx.core:core:1.13.1
@@ -61,23 +61,23 @@ androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.1
androidx.graphics:graphics-path:1.0.1
androidx.interpolator:interpolator:1.0.0
-androidx.lifecycle:lifecycle-common-java8:2.8.0
-androidx.lifecycle:lifecycle-common-jvm:2.8.0
-androidx.lifecycle:lifecycle-common:2.8.0
-androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.0
-androidx.lifecycle:lifecycle-livedata-core:2.8.0
-androidx.lifecycle:lifecycle-livedata:2.8.0
-androidx.lifecycle:lifecycle-process:2.8.0
-androidx.lifecycle:lifecycle-runtime-android:2.8.0
-androidx.lifecycle:lifecycle-runtime-compose-android:2.8.0
-androidx.lifecycle:lifecycle-runtime-compose:2.8.0
-androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.0
-androidx.lifecycle:lifecycle-runtime-ktx:2.8.0
-androidx.lifecycle:lifecycle-runtime:2.8.0
-androidx.lifecycle:lifecycle-viewmodel-android:2.8.0
-androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0
-androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.0
-androidx.lifecycle:lifecycle-viewmodel:2.8.0
+androidx.lifecycle:lifecycle-common-java8:2.8.3
+androidx.lifecycle:lifecycle-common-jvm:2.8.3
+androidx.lifecycle:lifecycle-common:2.8.3
+androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.3
+androidx.lifecycle:lifecycle-livedata-core:2.8.3
+androidx.lifecycle:lifecycle-livedata:2.8.3
+androidx.lifecycle:lifecycle-process:2.8.3
+androidx.lifecycle:lifecycle-runtime-android:2.8.3
+androidx.lifecycle:lifecycle-runtime-compose-android:2.8.3
+androidx.lifecycle:lifecycle-runtime-compose:2.8.3
+androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.3
+androidx.lifecycle:lifecycle-runtime-ktx:2.8.3
+androidx.lifecycle:lifecycle-runtime:2.8.3
+androidx.lifecycle:lifecycle-viewmodel-android:2.8.3
+androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3
+androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.3
+androidx.lifecycle:lifecycle-viewmodel:2.8.3
androidx.loader:loader:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.profileinstaller:profileinstaller:1.3.1
@@ -91,9 +91,9 @@ androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
-androidx.window:window-core-android:1.3.0-beta02
-androidx.window:window-core:1.3.0-beta02
-androidx.window:window:1.3.0-beta02
+androidx.window:window-core-android:1.3.0
+androidx.window:window-core:1.3.0
+androidx.window:window:1.3.0
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.51.1
@@ -109,10 +109,10 @@ io.coil-kt:coil-compose-base:2.6.0
io.coil-kt:coil-compose:2.6.0
io.coil-kt:coil:2.6.0
javax.inject:javax.inject:1
-org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0
+org.jetbrains.kotlin:kotlin-stdlib-common:2.0.20
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
-org.jetbrains.kotlin:kotlin-stdlib:2.0.0
+org.jetbrains.kotlin:kotlin-stdlib:2.0.20
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9c6989f67..1abc44bac 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -25,6 +25,7 @@ plugins {
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi)
+ alias(libs.plugins.kotlin.serialization)
}
android {
@@ -103,6 +104,7 @@ dependencies {
implementation(libs.androidx.window.core)
implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt)
+ implementation(libs.kotlinx.serialization.json)
ksp(libs.hilt.compiler)
@@ -114,6 +116,7 @@ dependencies {
testImplementation(projects.core.dataTest)
testImplementation(libs.hilt.android.testing)
testImplementation(projects.sync.syncTest)
+ testImplementation(libs.kotlin.test)
testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi)
diff --git a/app/dependencies/prodReleaseRuntimeClasspath.txt b/app/dependencies/prodReleaseRuntimeClasspath.txt
index 96e5940e8..9eec115fa 100644
--- a/app/dependencies/prodReleaseRuntimeClasspath.txt
+++ b/app/dependencies/prodReleaseRuntimeClasspath.txt
@@ -1,64 +1,64 @@
androidx.activity:activity-compose:1.8.2
androidx.activity:activity-ktx:1.8.2
androidx.activity:activity:1.8.2
-androidx.annotation:annotation-experimental:1.4.0
-androidx.annotation:annotation-jvm:1.8.0
-androidx.annotation:annotation:1.8.0
+androidx.annotation:annotation-experimental:1.4.1
+androidx.annotation:annotation-jvm:1.8.1
+androidx.annotation:annotation:1.8.1
androidx.appcompat:appcompat-resources:1.7.0
androidx.appcompat:appcompat:1.7.0
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.8.0
-androidx.collection:collection-jvm:1.4.0
-androidx.collection:collection-ktx:1.4.0
-androidx.collection:collection:1.4.0
-androidx.compose.animation:animation-android:1.7.0-beta01
-androidx.compose.animation:animation-core-android:1.7.0-beta01
-androidx.compose.animation:animation-core:1.7.0-beta01
-androidx.compose.animation:animation:1.7.0-beta01
-androidx.compose.foundation:foundation-android:1.7.0-beta01
-androidx.compose.foundation:foundation-layout-android:1.7.0-beta01
-androidx.compose.foundation:foundation-layout:1.7.0-beta01
-androidx.compose.foundation:foundation:1.7.0-beta01
-androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01
-androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0-beta01
-androidx.compose.material3.adaptive:adaptive-layout:1.0.0-beta01
-androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0-beta01
-androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-beta01
-androidx.compose.material3.adaptive:adaptive:1.0.0-beta01
-androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01
-androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01
-androidx.compose.material3:material3-android:1.3.0-beta01
-androidx.compose.material3:material3-window-size-class-android:1.3.0-beta01
-androidx.compose.material3:material3-window-size-class:1.3.0-beta01
-androidx.compose.material3:material3:1.3.0-beta01
-androidx.compose.material:material-icons-core-android:1.6.3
-androidx.compose.material:material-icons-core:1.6.3
-androidx.compose.material:material-icons-extended-android:1.6.3
-androidx.compose.material:material-icons-extended:1.6.3
-androidx.compose.material:material-ripple-android:1.7.0-beta01
-androidx.compose.material:material-ripple:1.7.0-beta01
-androidx.compose.runtime:runtime-android:1.7.0-beta01
-androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01
-androidx.compose.runtime:runtime-saveable:1.7.0-beta01
+androidx.collection:collection-jvm:1.4.2
+androidx.collection:collection-ktx:1.4.2
+androidx.collection:collection:1.4.2
+androidx.compose.animation:animation-android:1.7.0
+androidx.compose.animation:animation-core-android:1.7.0
+androidx.compose.animation:animation-core:1.7.0
+androidx.compose.animation:animation:1.7.0
+androidx.compose.foundation:foundation-android:1.7.0
+androidx.compose.foundation:foundation-layout-android:1.7.0
+androidx.compose.foundation:foundation-layout:1.7.0
+androidx.compose.foundation:foundation:1.7.0
+androidx.compose.material3.adaptive:adaptive-android:1.0.0
+androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0
+androidx.compose.material3.adaptive:adaptive-layout:1.0.0
+androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0
+androidx.compose.material3.adaptive:adaptive-navigation:1.0.0
+androidx.compose.material3.adaptive:adaptive:1.0.0
+androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0
+androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0
+androidx.compose.material3:material3-android:1.3.0
+androidx.compose.material3:material3-window-size-class-android:1.3.0
+androidx.compose.material3:material3-window-size-class:1.3.0
+androidx.compose.material3:material3:1.3.0
+androidx.compose.material:material-icons-core-android:1.7.0
+androidx.compose.material:material-icons-core:1.7.0
+androidx.compose.material:material-icons-extended-android:1.7.0
+androidx.compose.material:material-icons-extended:1.7.0
+androidx.compose.material:material-ripple-android:1.7.0
+androidx.compose.material:material-ripple:1.7.0
+androidx.compose.runtime:runtime-android:1.7.0
+androidx.compose.runtime:runtime-saveable-android:1.7.0
+androidx.compose.runtime:runtime-saveable:1.7.0
androidx.compose.runtime:runtime-tracing:1.0.0-beta01
-androidx.compose.runtime:runtime:1.7.0-beta01
-androidx.compose.ui:ui-android:1.7.0-beta01
-androidx.compose.ui:ui-geometry-android:1.7.0-beta01
-androidx.compose.ui:ui-geometry:1.7.0-beta01
-androidx.compose.ui:ui-graphics-android:1.7.0-beta01
-androidx.compose.ui:ui-graphics:1.7.0-beta01
-androidx.compose.ui:ui-text-android:1.7.0-beta01
-androidx.compose.ui:ui-text:1.7.0-beta01
-androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01
-androidx.compose.ui:ui-tooling-preview:1.7.0-beta01
-androidx.compose.ui:ui-unit-android:1.7.0-beta01
-androidx.compose.ui:ui-unit:1.7.0-beta01
-androidx.compose.ui:ui-util-android:1.7.0-beta01
-androidx.compose.ui:ui-util:1.7.0-beta01
-androidx.compose.ui:ui:1.7.0-beta01
-androidx.compose:compose-bom:2024.02.02
+androidx.compose.runtime:runtime:1.7.0
+androidx.compose.ui:ui-android:1.7.0
+androidx.compose.ui:ui-geometry-android:1.7.0
+androidx.compose.ui:ui-geometry:1.7.0
+androidx.compose.ui:ui-graphics-android:1.7.0
+androidx.compose.ui:ui-graphics:1.7.0
+androidx.compose.ui:ui-text-android:1.7.0
+androidx.compose.ui:ui-text:1.7.0
+androidx.compose.ui:ui-tooling-preview-android:1.7.0
+androidx.compose.ui:ui-tooling-preview:1.7.0
+androidx.compose.ui:ui-unit-android:1.7.0
+androidx.compose.ui:ui-unit:1.7.0
+androidx.compose.ui:ui-util-android:1.7.0
+androidx.compose.ui:ui-util:1.7.0
+androidx.compose.ui:ui:1.7.0
+androidx.compose:compose-bom:2024.09.00
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1
androidx.core:core-splashscreen:1.0.1
@@ -106,11 +106,11 @@ androidx.lifecycle:lifecycle-viewmodel:2.8.3
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
-androidx.navigation:navigation-common-ktx:2.8.0-alpha06
-androidx.navigation:navigation-common:2.8.0-alpha06
-androidx.navigation:navigation-compose:2.8.0-alpha06
-androidx.navigation:navigation-runtime-ktx:2.8.0-alpha06
-androidx.navigation:navigation-runtime:2.8.0-alpha06
+androidx.navigation:navigation-common-ktx:2.8.0
+androidx.navigation:navigation-common:2.8.0
+androidx.navigation:navigation-compose:2.8.0
+androidx.navigation:navigation-runtime-ktx:2.8.0
+androidx.navigation:navigation-runtime:2.8.0
androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
@@ -132,9 +132,9 @@ androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
-androidx.window:window-core-android:1.3.0-beta02
-androidx.window:window-core:1.3.0-beta02
-androidx.window:window:1.3.0-beta02
+androidx.window:window-core-android:1.3.0
+androidx.window:window-core:1.3.0
+androidx.window:window:1.3.0
androidx.work:work-runtime-ktx:2.9.0
androidx.work:work-runtime:2.9.0
com.caverock:androidsvg-aar:1.4
@@ -203,10 +203,10 @@ io.coil-kt:coil-svg:2.6.0
io.coil-kt:coil:2.6.0
javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0
-org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0
+org.jetbrains.kotlin:kotlin-stdlib-common:2.0.20
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
-org.jetbrains.kotlin:kotlin-stdlib:2.0.0
+org.jetbrains.kotlin:kotlin-stdlib:2.0.20
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 716305ab6..5b22f9865 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -47,9 +47,13 @@
-
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt
index 2f8572102..cffb13f34 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt
@@ -23,7 +23,6 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@@ -55,8 +54,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
-private const val TAG = "MainActivity"
-
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -148,7 +145,6 @@ class MainActivity : ComponentActivity() {
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
- @OptIn(ExperimentalMaterial3AdaptiveApi::class)
NiaApp(appState)
}
}
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt
index 39bc03de7..f878c003b 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
-import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
+import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
@@ -40,12 +40,11 @@ fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
- startDestination: String = FOR_YOU_ROUTE,
) {
val navController = appState.navController
NavHost(
navController = navController,
- startDestination = startDestination,
+ startDestination = ForYouRoute,
modifier = modifier,
) {
forYouScreen(onTopicClick = navController::navigateToInterests)
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt
index aca7d54ab..815061273 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt
@@ -16,9 +16,14 @@
package com.google.samples.apps.nowinandroid.navigation
+import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
+import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksRoute
+import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute
+import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
+import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.R as searchR
@@ -31,25 +36,29 @@ import com.google.samples.apps.nowinandroid.feature.search.R as searchR
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
- val iconTextId: Int,
- val titleTextId: Int,
+ @StringRes val iconTextId: Int,
+ @StringRes val titleTextId: Int,
+ val route: KClass<*>,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name,
+ route = ForYouRoute::class,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title,
+ route = BookmarksRoute::class,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests,
+ route = InterestsRoute::class,
),
}
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
index b47984ddb..6cdc32bb0 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt
@@ -60,6 +60,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
@@ -72,6 +73,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradien
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
+import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@@ -150,7 +152,7 @@ internal fun NiaApp(
appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination
- .isTopLevelDestinationInHierarchy(destination)
+ .isRouteInHierarchy(destination.route)
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
@@ -198,8 +200,10 @@ internal fun NiaApp(
) {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
- val shouldShowTopAppBar = destination != null
+ var shouldShowTopAppBar = false
+
if (destination != null) {
+ shouldShowTopAppBar = true
NiaTopAppBar(
titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search,
@@ -266,7 +270,7 @@ private fun Modifier.notificationDot(): Modifier =
}
}
-private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
+private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
this?.hierarchy?.any {
- it.route?.contains(destination.name, true) ?: false
+ it.hasRoute(route)
} ?: false
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt
index 519603579..75a294c01 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt
@@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
+import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -32,11 +33,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
-import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BOOKMARKS_ROUTE
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
-import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
-import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@@ -90,11 +88,10 @@ class NiaAppState(
.currentBackStackEntryAsState().value?.destination
val currentTopLevelDestination: TopLevelDestination?
- @Composable get() = when (currentDestination?.route) {
- FOR_YOU_ROUTE -> FOR_YOU
- BOOKMARKS_ROUTE -> BOOKMARKS
- INTERESTS_ROUTE -> INTERESTS
- else -> null
+ @Composable get() {
+ return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
+ currentDestination?.hasRoute(route = topLevelDestination.route) ?: false
+ }
}
val isOffline = networkMonitor.isOnline
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt
index 40ce9c116..3d37f3417 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt
@@ -18,19 +18,26 @@ package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
-import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
+import androidx.navigation.toRoute
+import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
+const val TOPIC_ID_KEY = "selectedTopicId"
+
@HiltViewModel
class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
- val selectedTopicId: StateFlow =
- savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG])
+
+ val route = savedStateHandle.toRoute()
+ val selectedTopicId: StateFlow = savedStateHandle.getStateFlow(
+ key = TOPIC_ID_KEY,
+ initialValue = route.initialTopicId,
+ )
fun onTopicClick(topicId: String?) {
- savedStateHandle[TOPIC_ID_ARG] = topicId
+ savedStateHandle[TOPIC_ID_KEY] = topicId
}
}
diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt
index 919cb44f2..669c6300a 100644
--- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt
+++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt
@@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.activity.compose.BackHandler
+import androidx.annotation.Keep
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
@@ -39,34 +40,26 @@ import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
-import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
-import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
-import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
-import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
+import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
-import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE
-import com.google.samples.apps.nowinandroid.feature.topic.navigation.createTopicRoute
+import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
+import kotlinx.serialization.Serializable
import java.util.UUID
-private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route"
+@Serializable internal object TopicPlaceholderRoute
+
+// TODO: Remove @Keep when https://issuetracker.google.com/353898971 is fixed
+@Keep
+@Serializable internal object DetailPaneNavHostRoute
fun NavGraphBuilder.interestsListDetailScreen() {
- composable(
- route = INTERESTS_ROUTE,
- arguments = listOf(
- navArgument(TOPIC_ID_ARG) {
- type = NavType.StringType
- defaultValue = null
- nullable = true
- },
- ),
- ) {
+ composable {
InterestsListDetailScreen()
}
}
@@ -104,8 +97,9 @@ internal fun InterestsListDetailScreen(
listDetailNavigator.navigateBack()
}
- var nestedNavHostStartDestination by remember {
- mutableStateOf(selectedTopicId?.let(::createTopicRoute) ?: TOPIC_ROUTE)
+ var nestedNavHostStartRoute by remember {
+ val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
+ mutableStateOf(route)
}
var nestedNavKey by rememberSaveable(
stateSaver = Saver({ it.toString() }, UUID::fromString),
@@ -122,11 +116,11 @@ internal fun InterestsListDetailScreen(
// If the detail pane was visible, then use the nestedNavController navigate call
// directly
nestedNavController.navigateToTopic(topicId) {
- popUpTo(DETAIL_PANE_NAVHOST_ROUTE)
+ popUpTo()
}
} else {
// Otherwise, recreate the NavHost entirely, and start at the new destination
- nestedNavHostStartDestination = createTopicRoute(topicId)
+ nestedNavHostStartRoute = TopicRoute(id = topicId)
nestedNavKey = UUID.randomUUID()
}
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
@@ -148,15 +142,15 @@ internal fun InterestsListDetailScreen(
key(nestedNavKey) {
NavHost(
navController = nestedNavController,
- startDestination = nestedNavHostStartDestination,
- route = DETAIL_PANE_NAVHOST_ROUTE,
+ startDestination = nestedNavHostStartRoute,
+ route = DetailPaneNavHostRoute::class,
) {
topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane,
)
- composable(route = TOPIC_ROUTE) {
+ composable {
TopicDetailPlaceholder()
}
}
diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt
index 2fc88e561..a2409dd89 100644
--- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt
+++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/DeviceConfigurationOverrideWindowInsets.kt
@@ -17,18 +17,17 @@
package com.google.samples.apps.nowinandroid.ui
import android.view.WindowInsets
-import android.widget.FrameLayout
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children
/**
- * A [DeviceConfigurationOverride] that allows overriding the [windowInsets] available
- * to the content under test.
+ * A [DeviceConfigurationOverride] that overrides the window insets for the contained content.
*/
@Suppress("ktlint:standard:function-naming")
fun DeviceConfigurationOverride.Companion.WindowInsets(
@@ -38,10 +37,17 @@ fun DeviceConfigurationOverride.Companion.WindowInsets(
val currentWindowInsets by rememberUpdatedState(windowInsets)
AndroidView(
factory = { context ->
- object : FrameLayout(context) {
+ object : AbstractComposeView(context) {
+ @Composable
+ override fun Content() {
+ currentContentUnderTest()
+ }
+
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
children.forEach {
- it.dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets())
+ it.dispatchApplyWindowInsets(
+ WindowInsets(currentWindowInsets.toWindowInsets()),
+ )
}
return WindowInsetsCompat.CONSUMED.toWindowInsets()!!
}
@@ -52,17 +58,10 @@ fun DeviceConfigurationOverride.Companion.WindowInsets(
*/
@Deprecated("Deprecated in Java")
override fun requestFitSystemWindows() {
- dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()!!)
+ dispatchApplyWindowInsets(WindowInsets(currentWindowInsets.toWindowInsets()!!))
}
- }.apply {
- addView(
- ComposeView(context).apply {
- setContent {
- currentContentUnderTest()
- }
- },
- )
}
},
+ update = { with(currentWindowInsets) { it.requestApplyInsets() } },
)
}
diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreenTest.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt
similarity index 63%
rename from app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreenTest.kt
rename to app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt
index 21ac3e920..a5b243537 100644
--- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreenTest.kt
+++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt
@@ -14,43 +14,49 @@
* limitations under the License.
*/
-package com.google.samples.apps.nowinandroid.ui.interests2pane
+package com.google.samples.apps.nowinandroid.ui
import androidx.activity.compose.BackHandler
-import androidx.compose.material3.adaptive.Posture
-import androidx.compose.material3.adaptive.WindowAdaptiveInfo
-import androidx.compose.ui.test.DeviceConfigurationOverride
-import androidx.compose.ui.test.ForcedSize
+import androidx.annotation.StringRes
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
-import androidx.compose.ui.unit.DpSize
-import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso
-import androidx.window.core.layout.WindowSizeClass
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Topic
-import com.google.samples.apps.nowinandroid.ui.stringResource
+import com.google.samples.apps.nowinandroid.ui.interests2pane.InterestsListDetailScreen
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
import javax.inject.Inject
+import kotlin.properties.ReadOnlyProperty
import kotlin.test.assertTrue
import com.google.samples.apps.nowinandroid.feature.topic.R as FeatureTopicR
+private const val EXPANDED_WIDTH = "w1200dp-h840dp"
+private const val COMPACT_WIDTH = "w412dp-h915dp"
+
@HiltAndroidTest
+@RunWith(RobolectricTestRunner::class)
+@Config(application = HiltTestApplication::class)
class InterestsListDetailScreenTest {
+
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@@ -64,6 +70,11 @@ class InterestsListDetailScreenTest {
@Inject
lateinit var topicsRepository: TopicsRepository
+ /** Convenience function for getting all topics during tests, */
+ private fun getTopics(): List = runBlocking {
+ topicsRepository.getTopics().first().sortedBy { it.name }
+ }
+
// The strings used for matching in these tests.
private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest)
private val listPaneTag = "interests:topics"
@@ -71,39 +82,18 @@ class InterestsListDetailScreenTest {
private val Topic.testTag
get() = "topic:${this.id}"
- // Overrides for device sizes.
- private enum class TestDeviceConfig(widthDp: Float, heightDp: Float) {
- Compact(412f, 915f),
- Expanded(1200f, 840f),
- ;
-
- val sizeOverride = DeviceConfigurationOverride.ForcedSize(DpSize(widthDp.dp, heightDp.dp))
- val adaptiveInfo = WindowAdaptiveInfo(
- windowSizeClass = WindowSizeClass.compute(widthDp, heightDp),
- windowPosture = Posture(),
- )
- }
-
@Before
fun setup() {
hiltRule.inject()
}
- /** Convenience function for getting all topics during tests, */
- private fun getTopics(): List = runBlocking {
- topicsRepository.getTopics().first()
- }
-
@Test
+ @Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() {
composeTestRule.apply {
setContent {
- with(TestDeviceConfig.Expanded) {
- DeviceConfigurationOverride(override = sizeOverride) {
- NiaTheme {
- InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
- }
- }
+ NiaTheme {
+ InterestsListDetailScreen()
}
}
@@ -113,15 +103,12 @@ class InterestsListDetailScreenTest {
}
@Test
+ @Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_initialState_showsListPane() {
composeTestRule.apply {
setContent {
- with(TestDeviceConfig.Compact) {
- DeviceConfigurationOverride(override = sizeOverride) {
- NiaTheme {
- InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
- }
- }
+ NiaTheme {
+ InterestsListDetailScreen()
}
}
@@ -131,15 +118,12 @@ class InterestsListDetailScreenTest {
}
@Test
+ @Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_topicSelected_updatesDetailPane() {
composeTestRule.apply {
setContent {
- with(TestDeviceConfig.Expanded) {
- DeviceConfigurationOverride(override = sizeOverride) {
- NiaTheme {
- InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
- }
- }
+ NiaTheme {
+ InterestsListDetailScreen()
}
}
@@ -153,15 +137,12 @@ class InterestsListDetailScreenTest {
}
@Test
+ @Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_topicSelected_showsTopicDetailPane() {
composeTestRule.apply {
setContent {
- with(TestDeviceConfig.Compact) {
- DeviceConfigurationOverride(override = sizeOverride) {
- NiaTheme {
- InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
- }
- }
+ NiaTheme {
+ InterestsListDetailScreen()
}
}
@@ -175,27 +156,25 @@ class InterestsListDetailScreenTest {
}
@Test
+ @Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_backPressFromTopicDetail_leavesInterests() {
var unhandledBackPress = false
composeTestRule.apply {
setContent {
- with(TestDeviceConfig.Expanded) {
- DeviceConfigurationOverride(override = sizeOverride) {
- NiaTheme {
- // Back press should not be handled by the two pane layout, and thus
- // "fall through" to this BackHandler.
- BackHandler {
- unhandledBackPress = true
- }
- InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
- }
+ NiaTheme {
+ // Back press should not be handled by the two pane layout, and thus
+ // "fall through" to this BackHandler.
+ BackHandler {
+ unhandledBackPress = true
}
+ InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
+ waitForIdle()
Espresso.pressBack()
assertTrue(unhandledBackPress)
@@ -203,21 +182,19 @@ class InterestsListDetailScreenTest {
}
@Test
+ @Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_backPressFromTopicDetail_showsListPane() {
composeTestRule.apply {
setContent {
- with(TestDeviceConfig.Compact) {
- DeviceConfigurationOverride(override = sizeOverride) {
- NiaTheme {
- InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
- }
- }
+ NiaTheme {
+ InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
+ waitForIdle()
Espresso.pressBack()
onNodeWithTag(listPaneTag).assertIsDisplayed()
@@ -226,3 +203,8 @@ class InterestsListDetailScreenTest {
}
}
}
+
+private fun AndroidComposeTestRule<*, *>.stringResource(
+ @StringRes resId: Int,
+): ReadOnlyProperty =
+ ReadOnlyProperty { _, _ -> activity.getString(resId) }
diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
index 9110e7fa3..6d0f213d4 100644
--- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
@@ -28,6 +28,7 @@ class AndroidFeatureConventionPlugin : Plugin {
pluginManager.apply {
apply("nowinandroid.android.library")
apply("nowinandroid.hilt")
+ apply("org.jetbrains.kotlin.plugin.serialization")
}
extensions.configure {
testOptions.animationsDisabled = true
@@ -41,8 +42,11 @@ class AndroidFeatureConventionPlugin : Plugin {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
+ add("implementation", libs.findLibrary("androidx.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
+ add("implementation", libs.findLibrary("kotlinx.serialization.json").get())
+ add("testImplementation", libs.findLibrary("androidx.navigation.testing").get())
add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
}
}
diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt
index f16a8051a..ffb6358c3 100644
--- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt
+++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt
@@ -65,8 +65,7 @@ internal fun Project.configureAndroidCompose(
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
- stabilityConfigurationFile = rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
-
- enableStrongSkippingMode = true
+ stabilityConfigurationFile =
+ rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
}
}
diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt
index 4447b8602..59eac2322 100644
--- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt
+++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt
@@ -35,12 +35,12 @@ import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
-import org.gradle.configurationcache.extensions.capitalized
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.register
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.process.ExecOperations
import java.io.File
+import java.util.Locale
import javax.inject.Inject
@CacheableTask
@@ -107,6 +107,10 @@ abstract class CheckBadgingTask : DefaultTask() {
}
}
+private fun String.capitalized() = replaceFirstChar {
+ if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
+}
+
fun Project.configureBadgingTasks(
baseExtension: BaseExtension,
componentsExtension: ApplicationAndroidComponentsExtension,
diff --git a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_20.png b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_20.png
index 570474cc1..74309056f 100644
Binary files a/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_20.png and b/core/designsystem/src/test/screenshots/LoadingWheel/LoadingWheel_animation_20.png differ
diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt
index a56bbcb8d..77dfa4394 100644
--- a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt
+++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt
@@ -22,6 +22,7 @@ import kotlinx.datetime.Instant
* A [NewsResource] with additional user information such as whether the user is following the
* news resource's topics and whether they have saved (bookmarked) this news resource.
*/
+@ConsistentCopyVisibility
data class UserNewsResource internal constructor(
val id: String,
val title: String,
diff --git a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt
index 5da88102a..3fc8114dd 100644
--- a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt
+++ b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt
@@ -44,7 +44,10 @@ private const val NEWS_NOTIFICATION_SUMMARY_ID = 1
private const val NEWS_NOTIFICATION_CHANNEL_ID = ""
private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS"
private const val DEEP_LINK_SCHEME_AND_HOST = "https://www.nowinandroid.apps.samples.google.com"
-private const val FOR_YOU_PATH = "foryou"
+private const val DEEP_LINK_FOR_YOU_PATH = "foryou"
+private const val DEEP_LINK_BASE_PATH = "$DEEP_LINK_SCHEME_AND_HOST/$DEEP_LINK_FOR_YOU_PATH"
+const val DEEP_LINK_NEWS_RESOURCE_ID_KEY = "linkedNewsResourceId"
+const val DEEP_LINK_URI_PATTERN = "$DEEP_LINK_BASE_PATH/{$DEEP_LINK_NEWS_RESOURCE_ID_KEY}"
/**
* Implementation of [Notifier] that displays notifications in the system tray.
@@ -161,4 +164,4 @@ private fun Context.newsPendingIntent(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
-private fun NewsResource.newsDeepLinkUri() = "$DEEP_LINK_SCHEME_AND_HOST/$FOR_YOU_PATH/$id".toUri()
+private fun NewsResource.newsDeepLinkUri() = "$DEEP_LINK_BASE_PATH/$id".toUri()
diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt
index afdb584a2..c22a02fa1 100644
--- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt
+++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt
@@ -87,7 +87,7 @@ fun LazyStaggeredGridScope.newsFeed(
onTopicClick = onTopicClick,
modifier = Modifier
.padding(horizontal = 8.dp)
- .animateItemPlacement(),
+ .animateItem(),
)
}
}
diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt
index e60c498eb..7c41d74d0 100644
--- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt
+++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt
@@ -16,8 +16,15 @@
package com.google.samples.apps.nowinandroid.core.ui
+import android.content.ClipData
+import android.os.Build.VERSION
+import android.os.Build.VERSION_CODES
+import android.view.View
import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
+import androidx.compose.foundation.draganddrop.dragAndDropSource
+import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -45,6 +52,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
@@ -77,6 +85,7 @@ import java.util.Locale
* [NewsResource] card used on the following screens: For You, Saved
*/
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NewsResourceCardExpanded(
userNewsResource: UserNewsResource,
@@ -88,6 +97,19 @@ fun NewsResourceCardExpanded(
modifier: Modifier = Modifier,
) {
val clickActionLabel = stringResource(R.string.core_ui_card_tap_action)
+ val sharingLabel = stringResource(R.string.core_ui_feed_sharing)
+ val sharingContent = stringResource(
+ R.string.core_ui_feed_sharing_data,
+ userNewsResource.title,
+ userNewsResource.url,
+ )
+
+ val dragAndDropFlags = if (VERSION.SDK_INT >= VERSION_CODES.N) {
+ View.DRAG_FLAG_GLOBAL
+ } else {
+ 0
+ }
+
Card(
onClick = onClick,
shape = RoundedCornerShape(16.dp),
@@ -112,7 +134,23 @@ fun NewsResourceCardExpanded(
Row {
NewsResourceTitle(
userNewsResource.title,
- modifier = Modifier.fillMaxWidth((.8f)),
+ modifier = Modifier
+ .fillMaxWidth((.8f))
+ .dragAndDropSource {
+ detectTapGestures(
+ onLongPress = {
+ startTransfer(
+ DragAndDropTransferData(
+ ClipData.newPlainText(
+ sharingLabel,
+ sharingContent,
+ ),
+ flags = dragAndDropFlags,
+ ),
+ )
+ },
+ )
+ },
)
Spacer(modifier = Modifier.weight(1f))
BookmarkButton(isBookmarked, onToggleBookmark)
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
index ab76748ef..a97746a9c 100644
--- a/core/ui/src/main/res/values/strings.xml
+++ b/core/ui/src/main/res/values/strings.xml
@@ -29,4 +29,6 @@
Follow interest
Unfollow interest
+ Feed sharing
+ %1$s: %2$s
diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt
index 13d0baef0..ea8d525ab 100644
--- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt
+++ b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt
@@ -21,16 +21,18 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
+import kotlinx.serialization.Serializable
-const val BOOKMARKS_ROUTE = "bookmarks_route"
+@Serializable object BookmarksRoute
-fun NavController.navigateToBookmarks(navOptions: NavOptions) = navigate(BOOKMARKS_ROUTE, navOptions)
+fun NavController.navigateToBookmarks(navOptions: NavOptions) =
+ navigate(route = BookmarksRoute, navOptions)
fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean,
) {
- composable(route = BOOKMARKS_ROUTE) {
+ composable {
BookmarksRoute(onTopicClick, onShowSnackbar)
}
}
diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts
index 004fe8ad6..41d5b16a2 100644
--- a/feature/foryou/build.gradle.kts
+++ b/feature/foryou/build.gradle.kts
@@ -29,6 +29,7 @@ dependencies {
implementation(libs.accompanist.permissions)
implementation(projects.core.data)
implementation(projects.core.domain)
+ implementation(project(":core:notifications"))
testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric)
diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
index 885020636..0f345aa80 100644
--- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
+++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt
@@ -106,7 +106,7 @@ import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable
-internal fun ForYouRoute(
+internal fun ForYouScreen(
onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(),
diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
index 85035a77a..4b6cd39c9 100644
--- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
+++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt
@@ -27,8 +27,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
+import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
-import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -55,7 +55,7 @@ class ForYouViewModel @Inject constructor(
userDataRepository.userData.map { !it.shouldHideOnboarding }
val deepLinkedNewsResource = savedStateHandle.getStateFlow(
- key = LINKED_NEWS_RESOURCE_ID,
+ key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
null,
)
.flatMapLatest { newsResourceId ->
@@ -129,7 +129,7 @@ class ForYouViewModel @Inject constructor(
fun onDeepLinkOpened(newsResourceId: String) {
if (newsResourceId == deepLinkedNewsResource.value?.id) {
- savedStateHandle[LINKED_NEWS_RESOURCE_ID] = null
+ savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = null
}
analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId)
viewModelScope.launch {
@@ -153,7 +153,7 @@ private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) =
type = "news_deep_link_opened",
extras = listOf(
Param(
- key = LINKED_NEWS_RESOURCE_ID,
+ key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = newsResourceId,
),
),
diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
index 8e94a491a..9d98f1618 100644
--- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
+++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt
@@ -19,29 +19,31 @@ package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
-import androidx.navigation.NavType
import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
-import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute
+import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_URI_PATTERN
+import com.google.samples.apps.nowinandroid.feature.foryou.ForYouScreen
+import kotlinx.serialization.Serializable
-const val LINKED_NEWS_RESOURCE_ID = "linkedNewsResourceId"
-const val FOR_YOU_ROUTE = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}"
-private const val DEEP_LINK_URI_PATTERN =
- "https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}"
+@Serializable data object ForYouRoute
-fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(FOR_YOU_ROUTE, navOptions)
+fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(route = ForYouRoute, navOptions)
fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
- composable(
- route = FOR_YOU_ROUTE,
+ composable(
deepLinks = listOf(
- navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN },
- ),
- arguments = listOf(
- navArgument(LINKED_NEWS_RESOURCE_ID) { type = NavType.StringType },
+ navDeepLink {
+ /**
+ * This destination has a deep link that enables a specific news resource to be
+ * opened from a notification (@see SystemTrayNotifier for more). The news resource
+ * ID is sent in the URI rather than being modelled in the route type because it's
+ * transient data (stored in SavedStateHandle) that is cleared after the user has
+ * opened the news resource.
+ */
+ uriPattern = DEEP_LINK_URI_PATTERN
+ },
),
) {
- ForYouRoute(onTopicClick)
+ ForYouScreen(onTopicClick)
}
}
diff --git a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
index 2fbdf0a79..eece140ac 100644
--- a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
+++ b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt
@@ -26,6 +26,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
+import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_NEWS_RESOURCE_ID_KEY
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
@@ -34,7 +35,6 @@ import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.testing.util.TestAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
-import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -472,7 +472,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(emptyUserData)
- savedStateHandle[LINKED_NEWS_RESOURCE_ID] = sampleNewsResources.first().id
+ savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = sampleNewsResources.first().id
assertEquals(
expected = UserNewsResource(
@@ -496,7 +496,7 @@ class ForYouViewModelTest {
type = "news_deep_link_opened",
extras = listOf(
Param(
- key = LINKED_NEWS_RESOURCE_ID,
+ key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = sampleNewsResources.first().id,
),
),
diff --git a/feature/interests/build.gradle.kts b/feature/interests/build.gradle.kts
index ca91ba2c4..2b84b135f 100644
--- a/feature/interests/build.gradle.kts
+++ b/feature/interests/build.gradle.kts
@@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.domain)
testImplementation(projects.core.testing)
+ testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
index b369ac5ab..67cc8884f 100644
--- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
+++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt
@@ -19,11 +19,12 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
-import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
+import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -39,7 +40,14 @@ class InterestsViewModel @Inject constructor(
getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() {
- val selectedTopicId: StateFlow = savedStateHandle.getStateFlow(TOPIC_ID_ARG, null)
+ // Key used to save and retrieve the currently selected topic id from saved state.
+ private val selectedTopicIdKey = "selectedTopicIdKey"
+
+ private val interestsRoute: InterestsRoute = savedStateHandle.toRoute()
+ private val selectedTopicId = savedStateHandle.getStateFlow(
+ key = selectedTopicIdKey,
+ initialValue = interestsRoute.initialTopicId,
+ )
val uiState: StateFlow = combine(
selectedTopicId,
@@ -58,7 +66,7 @@ class InterestsViewModel @Inject constructor(
}
fun onTopicClick(topicId: String?) {
- savedStateHandle[TOPIC_ID_ARG] = topicId
+ savedStateHandle[selectedTopicIdKey] = topicId
}
}
diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt
index 8a0f2d130..d83e4a9b2 100644
--- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt
+++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt
@@ -17,39 +17,17 @@
package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.navigation.NavController
-import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
-import androidx.navigation.NavType
-import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
+import kotlinx.serialization.Serializable
-const val TOPIC_ID_ARG = "topicId"
-const val INTERESTS_ROUTE_BASE = "interests_route"
-const val INTERESTS_ROUTE = "$INTERESTS_ROUTE_BASE?$TOPIC_ID_ARG={$TOPIC_ID_ARG}"
+@Serializable data class InterestsRoute(
+ // The ID of the topic which will be initially selected at this destination
+ val initialTopicId: String? = null,
+)
-fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOptions? = null) {
- val route = if (topicId != null) {
- "${INTERESTS_ROUTE_BASE}?${TOPIC_ID_ARG}=$topicId"
- } else {
- INTERESTS_ROUTE_BASE
- }
- navigate(route, navOptions)
-}
-
-fun NavGraphBuilder.interestsScreen(
- onTopicClick: (String) -> Unit,
+fun NavController.navigateToInterests(
+ initialTopicId: String? = null,
+ navOptions: NavOptions? = null,
) {
- composable(
- route = INTERESTS_ROUTE,
- arguments = listOf(
- navArgument(TOPIC_ID_ARG) {
- defaultValue = null
- nullable = true
- type = NavType.StringType
- },
- ),
- ) {
- InterestsRoute(onTopicClick = onTopicClick)
- }
+ navigate(route = InterestsRoute(initialTopicId), navOptions)
}
diff --git a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
index 63d3c49b7..987a5bc01 100644
--- a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
+++ b/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt
@@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.interests
import androidx.lifecycle.SavedStateHandle
+import androidx.navigation.testing.invoke
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic
@@ -25,7 +26,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
-import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
+import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -33,12 +34,21 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
+ *
+ * These tests use Robolectric because the subject under test (the ViewModel) uses
+ * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`.
+ *
+ * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency.
+ * See https://issuetracker.google.com/340966212.
*/
+@RunWith(RobolectricTestRunner::class)
class InterestsViewModelTest {
@get:Rule
@@ -55,7 +65,9 @@ class InterestsViewModelTest {
@Before
fun setup() {
viewModel = InterestsViewModel(
- savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
+ savedStateHandle = SavedStateHandle(
+ route = InterestsRoute(initialTopicId = testInputTopics[0].topic.id),
+ ),
userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase,
)
diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts
index c5f1f6ad0..5bb659c35 100644
--- a/feature/search/build.gradle.kts
+++ b/feature/search/build.gradle.kts
@@ -27,7 +27,6 @@ android {
dependencies {
implementation(projects.core.data)
implementation(projects.core.domain)
- implementation(projects.core.ui)
testImplementation(projects.core.testing)
diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt
index 86b1eb717..ff91941a8 100644
--- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt
+++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt
@@ -41,7 +41,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
@@ -66,6 +65,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -73,6 +73,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -227,23 +228,31 @@ fun EmptySearchResultBody(
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 24.dp),
)
- val interests = stringResource(id = searchR.string.feature_search_interests)
val tryAnotherSearchString = buildAnnotatedString {
append(stringResource(id = searchR.string.feature_search_try_another_search))
append(" ")
- withStyle(
- style = SpanStyle(
- textDecoration = TextDecoration.Underline,
- fontWeight = FontWeight.Bold,
+ withLink(
+ LinkAnnotation.Clickable(
+ tag = "",
+ linkInteractionListener = {
+ onInterestsClick()
+ },
),
) {
- pushStringAnnotation(tag = interests, annotation = interests)
- append(interests)
+ withStyle(
+ style = SpanStyle(
+ textDecoration = TextDecoration.Underline,
+ fontWeight = FontWeight.Bold,
+ ),
+ ) {
+ append(stringResource(id = searchR.string.feature_search_interests))
+ }
}
+
append(" ")
append(stringResource(id = searchR.string.feature_search_to_browse_topics))
}
- ClickableText(
+ Text(
text = tryAnotherSearchString,
style = MaterialTheme.typography.bodyLarge.merge(
TextStyle(
@@ -252,13 +261,8 @@ fun EmptySearchResultBody(
),
),
modifier = Modifier
- .padding(start = 36.dp, end = 36.dp, bottom = 24.dp)
- .clickable {},
- ) { offset ->
- tryAnotherSearchString.getStringAnnotations(start = offset, end = offset)
- .firstOrNull()
- ?.let { onInterestsClick() }
- }
+ .padding(start = 36.dp, end = 36.dp, bottom = 24.dp),
+ )
}
}
diff --git a/feature/topic/build.gradle.kts b/feature/topic/build.gradle.kts
index 726920af1..bd8b59ec8 100644
--- a/feature/topic/build.gradle.kts
+++ b/feature/topic/build.gradle.kts
@@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.data)
testImplementation(projects.core.testing)
+ testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
index 5ac766675..13fbab784 100644
--- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
+++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt
@@ -71,7 +71,7 @@ import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string
@Composable
-internal fun TopicRoute(
+internal fun TopicScreen(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
index 255e40f8b..ba8baad14 100644
--- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
+++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
@@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
@@ -28,7 +29,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult
-import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs
+import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -47,12 +48,10 @@ class TopicViewModel @Inject constructor(
userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() {
- private val topicArgs: TopicArgs = TopicArgs(savedStateHandle)
-
- val topicId = topicArgs.topicId
+ val topicId = savedStateHandle.toRoute().id
val topicUiState: StateFlow = topicUiState(
- topicId = topicArgs.topicId,
+ topicId = topicId,
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
)
@@ -63,7 +62,7 @@ class TopicViewModel @Inject constructor(
)
val newsUiState: StateFlow = newsUiState(
- topicId = topicArgs.topicId,
+ topicId = topicId,
userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository,
)
@@ -75,7 +74,7 @@ class TopicViewModel @Inject constructor(
fun followTopicToggle(followed: Boolean) {
viewModelScope.launch {
- userDataRepository.setTopicIdFollowed(topicArgs.topicId, followed)
+ userDataRepository.setTopicIdFollowed(topicId, followed)
}
}
diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt
index 394c53303..fabb82b10 100644
--- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt
+++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt
@@ -16,53 +16,28 @@
package com.google.samples.apps.nowinandroid.feature.topic.navigation
-import androidx.annotation.VisibleForTesting
-import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder
-import androidx.navigation.NavType
import androidx.navigation.compose.composable
-import androidx.navigation.navArgument
-import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute
-import java.net.URLDecoder
-import java.net.URLEncoder
-import kotlin.text.Charsets.UTF_8
+import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen
+import kotlinx.serialization.Serializable
-private val URL_CHARACTER_ENCODING = UTF_8.name()
-
-@VisibleForTesting
-internal const val TOPIC_ID_ARG = "topicId"
-const val TOPIC_ROUTE = "topic_route"
-
-internal class TopicArgs(val topicId: String) {
- constructor(savedStateHandle: SavedStateHandle) :
- this(URLDecoder.decode(checkNotNull(savedStateHandle[TOPIC_ID_ARG]), URL_CHARACTER_ENCODING))
-}
+@Serializable data class TopicRoute(val id: String)
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
- navigate(createTopicRoute(topicId)) {
+ navigate(route = TopicRoute(topicId)) {
navOptions()
}
}
-fun createTopicRoute(topicId: String): String {
- val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
- return "$TOPIC_ROUTE/$encodedId"
-}
-
fun NavGraphBuilder.topicScreen(
showBackButton: Boolean,
onBackClick: () -> Unit,
onTopicClick: (String) -> Unit,
) {
- composable(
- route = "topic_route/{$TOPIC_ID_ARG}",
- arguments = listOf(
- navArgument(TOPIC_ID_ARG) { type = NavType.StringType },
- ),
- ) {
- TopicRoute(
+ composable {
+ TopicScreen(
showBackButton = showBackButton,
onBackClick = onBackClick,
onTopicClick = onTopicClick,
diff --git a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
index 565732f59..c14e62e31 100644
--- a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
+++ b/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt
@@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle
+import androidx.navigation.testing.invoke
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@@ -25,7 +26,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepo
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
-import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ID_ARG
+import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
@@ -36,13 +37,22 @@ import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals
import kotlin.test.assertIs
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
+ *
+ * These tests use Robolectric because the subject under test (the ViewModel) uses
+ * `SavedStateHandle.toRoute` which has a dependency on `android.os.Bundle`.
+ *
+ * TODO: Remove Robolectric if/when AndroidX Navigation API is updated to remove Android dependency.
+ * * See b/340966212.
*/
+@RunWith(RobolectricTestRunner::class)
class TopicViewModelTest {
@get:Rule
@@ -60,7 +70,9 @@ class TopicViewModelTest {
@Before
fun setup() {
viewModel = TopicViewModel(
- savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)),
+ savedStateHandle = SavedStateHandle(
+ route = TopicRoute(id = testInputTopics[0].topic.id),
+ ),
userDataRepository = userDataRepository,
topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7047ac665..ba9c36bfd 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -2,15 +2,12 @@
accompanist = "0.34.0"
androidDesugarJdkLibs = "2.0.4"
# AGP and tools should be updated together
-androidGradlePlugin = "8.4.0"
-androidTools = "31.4.1"
+androidGradlePlugin = "8.6.0"
+androidTools = "31.6.0"
androidxActivity = "1.8.2"
androidxAppCompat = "1.7.0"
androidxBrowser = "1.8.0"
-androidxComposeAlpha = "1.7.0-beta01"
-androidxComposeBom = "2024.02.02"
-androidxComposeMaterial3Adaptive = "1.0.0-beta01"
-androidxComposeMaterial3AdaptiveNavigationSuite = "1.3.0-beta01"
+androidxComposeBom = "2024.09.00"
androidxComposeRuntimeTracing = "1.0.0-beta01"
androidxCore = "1.12.0"
androidxCoreSplashscreen = "1.0.1"
@@ -20,7 +17,7 @@ androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.8.3"
androidxMacroBenchmark = "1.2.4"
androidxMetrics = "1.0.0-alpha04"
-androidxNavigation = "2.8.0-alpha06"
+androidxNavigation = "2.8.0"
androidxProfileinstaller = "1.3.1"
androidxTestCore = "1.5.0"
androidxTestExt = "1.1.5"
@@ -28,7 +25,7 @@ androidxTestRules = "1.5.0"
androidxTestRunner = "1.5.2"
androidxTracing = "1.3.0-alpha02"
androidxUiAutomator = "2.3.0"
-androidxWindowManager = "1.3.0-alpha03"
+androidxWindowManager = "1.3.0"
androidxWork = "2.9.0"
coil = "2.6.0"
dependencyGuard = "0.5.0"
@@ -42,11 +39,11 @@ hilt = "2.51.1"
hiltExt = "1.1.0"
jacoco = "0.8.7"
junit4 = "4.13.2"
-kotlin = "2.0.0"
+kotlin = "2.0.20"
kotlinxCoroutines = "1.8.0"
kotlinxDatetime = "0.5.0"
kotlinxSerializationJson = "1.6.3"
-ksp = "2.0.0-1.0.21"
+ksp = "2.0.20-1.0.24"
moduleGraph = "2.5.0"
okhttp = "4.12.0"
protobuf = "4.26.1"
@@ -71,18 +68,18 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version
androidx-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "androidxMacroBenchmark" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
-androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "androidxComposeAlpha" }
+androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
-androidx-compose-material3-navigationSuite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "androidxComposeMaterial3AdaptiveNavigationSuite" }
-androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "androidxComposeMaterial3Adaptive" }
-androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "androidxComposeMaterial3Adaptive" }
-androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "androidxComposeMaterial3Adaptive" }
+androidx-compose-material3-navigationSuite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
+androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" }
+androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" }
+androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation" }
androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidxComposeRuntimeTracing" }
-androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "androidxComposeAlpha" }
+androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
@@ -128,6 +125,7 @@ hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.r
hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
javax-inject = { module = "javax.inject:javax.inject", version = "1" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" }
+kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index e6441136f..a4b76b953 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index b82aa23a4..9355b4155 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index 1aa94a426..f5feea6d6 100755
--- a/gradlew
+++ b/gradlew
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,8 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
diff --git a/gradlew.bat b/gradlew.bat
index 7101f8e46..9b42019c7 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################