Merge branch 'refs/heads/android-remote-main' into hot-fix-dependencies

Change-Id: If411597e83749126093fed5eb2e0c28ed60f9cb8
pull/1545/head
Jaehwa Noh 1 year ago
commit a39c0d2254

@ -35,7 +35,7 @@ jobs:
java-version: 17 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
validate-wrappers: true validate-wrappers: true
gradle-home-cache-cleanup: true gradle-home-cache-cleanup: true
@ -187,7 +187,7 @@ jobs:
java-version: 17 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
validate-wrappers: true validate-wrappers: true
gradle-home-cache-cleanup: true gradle-home-cache-cleanup: true
@ -222,7 +222,7 @@ jobs:
- name: Display local test coverage (only API 30) - name: Display local test coverage (only API 30)
if: matrix.api-level == 30 if: matrix.api-level == 30
id: jacoco id: jacoco
uses: madrapps/jacoco-report@v1.6.1 uses: madrapps/jacoco-report@v1.7.0
with: with:
title: Combined test coverage report title: Combined test coverage report
min-coverage-overall: 40 min-coverage-overall: 40

@ -32,7 +32,7 @@ jobs:
java-version: 17 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v4
with: with:
validate-wrappers: true validate-wrappers: true
gradle-home-cache-cleanup: true gradle-home-cache-cleanup: true

@ -111,7 +111,8 @@ Examples:
To run the tests execute the following gradle tasks: 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. - `connectedDemoDebugAndroidTest` run all instrumented tests against the `demoDebug` variant.
**Note:** You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute **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 - `compareRoborazziDemoDebug` create comparison images between failed tests and the known correct
images. These can also be found in `modulename/src/test/screenshots`. 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. 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 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 `main` branch before starting work. After making changes, `verifyRoborazziDemoDebug` will identify only

@ -12,45 +12,45 @@ androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0 androidx.collection:collection-jvm:1.4.0
androidx.collection:collection-ktx:1.4.0 androidx.collection:collection-ktx:1.4.0
androidx.collection:collection:1.4.0 androidx.collection:collection:1.4.0
androidx.compose.animation:animation-android:1.7.0-beta01 androidx.compose.animation:animation-android:1.7.0
androidx.compose.animation:animation-core-android:1.7.0-beta01 androidx.compose.animation:animation-core-android:1.7.0
androidx.compose.animation:animation-core:1.7.0-beta01 androidx.compose.animation:animation-core:1.7.0
androidx.compose.animation:animation:1.7.0-beta01 androidx.compose.animation:animation:1.7.0
androidx.compose.foundation:foundation-android:1.7.0-beta01 androidx.compose.foundation:foundation-android:1.7.0
androidx.compose.foundation:foundation-layout-android:1.7.0-beta01 androidx.compose.foundation:foundation-layout-android:1.7.0
androidx.compose.foundation:foundation-layout:1.7.0-beta01 androidx.compose.foundation:foundation-layout:1.7.0
androidx.compose.foundation:foundation:1.7.0-beta01 androidx.compose.foundation:foundation:1.7.0
androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01 androidx.compose.material3.adaptive:adaptive-android:1.0.0
androidx.compose.material3.adaptive:adaptive:1.0.0-beta01 androidx.compose.material3.adaptive:adaptive:1.0.0
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01 androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01 androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0
androidx.compose.material3:material3-android:1.3.0-beta01 androidx.compose.material3:material3-android:1.3.0
androidx.compose.material3:material3:1.3.0-beta01 androidx.compose.material3:material3:1.3.0
androidx.compose.material:material-icons-core-android:1.6.3 androidx.compose.material:material-icons-core-android:1.7.0
androidx.compose.material:material-icons-core:1.6.3 androidx.compose.material:material-icons-core:1.7.0
androidx.compose.material:material-icons-extended-android:1.6.3 androidx.compose.material:material-icons-extended-android:1.7.0
androidx.compose.material:material-icons-extended:1.6.3 androidx.compose.material:material-icons-extended:1.7.0
androidx.compose.material:material-ripple-android:1.7.0-beta01 androidx.compose.material:material-ripple-android:1.7.0
androidx.compose.material:material-ripple:1.7.0-beta01 androidx.compose.material:material-ripple:1.7.0
androidx.compose.runtime:runtime-android:1.7.0-beta01 androidx.compose.runtime:runtime-android:1.7.0
androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01 androidx.compose.runtime:runtime-saveable-android:1.7.0
androidx.compose.runtime:runtime-saveable:1.7.0-beta01 androidx.compose.runtime:runtime-saveable:1.7.0
androidx.compose.runtime:runtime:1.7.0-beta01 androidx.compose.runtime:runtime:1.7.0
androidx.compose.ui:ui-android:1.7.0-beta01 androidx.compose.ui:ui-android:1.7.0
androidx.compose.ui:ui-geometry-android:1.7.0-beta01 androidx.compose.ui:ui-geometry-android:1.7.0
androidx.compose.ui:ui-geometry:1.7.0-beta01 androidx.compose.ui:ui-geometry:1.7.0
androidx.compose.ui:ui-graphics-android:1.7.0-beta01 androidx.compose.ui:ui-graphics-android:1.7.0
androidx.compose.ui:ui-graphics:1.7.0-beta01 androidx.compose.ui:ui-graphics:1.7.0
androidx.compose.ui:ui-text-android:1.7.0-beta01 androidx.compose.ui:ui-text-android:1.7.0
androidx.compose.ui:ui-text:1.7.0-beta01 androidx.compose.ui:ui-text:1.7.0
androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01 androidx.compose.ui:ui-tooling-preview-android:1.7.0
androidx.compose.ui:ui-tooling-preview:1.7.0-beta01 androidx.compose.ui:ui-tooling-preview:1.7.0
androidx.compose.ui:ui-unit-android:1.7.0-beta01 androidx.compose.ui:ui-unit-android:1.7.0
androidx.compose.ui:ui-unit:1.7.0-beta01 androidx.compose.ui:ui-unit:1.7.0
androidx.compose.ui:ui-util-android:1.7.0-beta01 androidx.compose.ui:ui-util-android:1.7.0
androidx.compose.ui:ui-util:1.7.0-beta01 androidx.compose.ui:ui-util:1.7.0
androidx.compose.ui:ui:1.7.0-beta01 androidx.compose.ui:ui:1.7.0
androidx.compose:compose-bom:2024.02.02 androidx.compose:compose-bom:2024.09.00
androidx.concurrent:concurrent-futures:1.1.0 androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1 androidx.core:core-ktx:1.13.1
androidx.core:core: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.fragment:fragment:1.5.1
androidx.graphics:graphics-path:1.0.1 androidx.graphics:graphics-path:1.0.1
androidx.interpolator:interpolator:1.0.0 androidx.interpolator:interpolator:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.8.0 androidx.lifecycle:lifecycle-common-java8:2.8.3
androidx.lifecycle:lifecycle-common-jvm:2.8.0 androidx.lifecycle:lifecycle-common-jvm:2.8.3
androidx.lifecycle:lifecycle-common:2.8.0 androidx.lifecycle:lifecycle-common:2.8.3
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.0 androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.3
androidx.lifecycle:lifecycle-livedata-core:2.8.0 androidx.lifecycle:lifecycle-livedata-core:2.8.3
androidx.lifecycle:lifecycle-livedata:2.8.0 androidx.lifecycle:lifecycle-livedata:2.8.3
androidx.lifecycle:lifecycle-process:2.8.0 androidx.lifecycle:lifecycle-process:2.8.3
androidx.lifecycle:lifecycle-runtime-android:2.8.0 androidx.lifecycle:lifecycle-runtime-android:2.8.3
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.0 androidx.lifecycle:lifecycle-runtime-compose-android:2.8.3
androidx.lifecycle:lifecycle-runtime-compose:2.8.0 androidx.lifecycle:lifecycle-runtime-compose:2.8.3
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.0 androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.3
androidx.lifecycle:lifecycle-runtime-ktx:2.8.0 androidx.lifecycle:lifecycle-runtime-ktx:2.8.3
androidx.lifecycle:lifecycle-runtime:2.8.0 androidx.lifecycle:lifecycle-runtime:2.8.3
androidx.lifecycle:lifecycle-viewmodel-android:2.8.0 androidx.lifecycle:lifecycle-viewmodel-android:2.8.3
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0 androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.0 androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.3
androidx.lifecycle:lifecycle-viewmodel:2.8.0 androidx.lifecycle:lifecycle-viewmodel:2.8.3
androidx.loader:loader:1.0.0 androidx.loader:loader:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04 androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.profileinstaller:profileinstaller:1.3.1 androidx.profileinstaller:profileinstaller:1.3.1
@ -91,9 +91,9 @@ androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1 androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0 androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0 androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0-beta02 androidx.window:window-core-android:1.3.0
androidx.window:window-core:1.3.0-beta02 androidx.window:window-core:1.3.0
androidx.window:window:1.3.0-beta02 androidx.window:window:1.3.0
com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.code.findbugs:jsr305:3.0.2 com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.51.1 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-compose:2.6.0
io.coil-kt:coil:2.6.0 io.coil-kt:coil:2.6.0
javax.inject:javax.inject:1 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-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8: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-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3

@ -25,6 +25,7 @@ plugins {
id("com.google.android.gms.oss-licenses-plugin") id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.baselineprofile) alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi) alias(libs.plugins.roborazzi)
alias(libs.plugins.kotlin.serialization)
} }
android { android {
@ -103,6 +104,7 @@ dependencies {
implementation(libs.androidx.window.core) implementation(libs.androidx.window.core)
implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt) implementation(libs.coil.kt)
implementation(libs.kotlinx.serialization.json)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
@ -114,6 +116,7 @@ dependencies {
testImplementation(projects.core.dataTest) testImplementation(projects.core.dataTest)
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(projects.sync.syncTest) testImplementation(projects.sync.syncTest)
testImplementation(libs.kotlin.test)
testDemoImplementation(libs.robolectric) testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi) testDemoImplementation(libs.roborazzi)

@ -1,64 +1,64 @@
androidx.activity:activity-compose:1.8.2 androidx.activity:activity-compose:1.8.2
androidx.activity:activity-ktx:1.8.2 androidx.activity:activity-ktx:1.8.2
androidx.activity:activity:1.8.2 androidx.activity:activity:1.8.2
androidx.annotation:annotation-experimental:1.4.0 androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-jvm:1.8.0 androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.8.0 androidx.annotation:annotation:1.8.1
androidx.appcompat:appcompat-resources:1.7.0 androidx.appcompat:appcompat-resources:1.7.0
androidx.appcompat:appcompat:1.7.0 androidx.appcompat:appcompat:1.7.0
androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0 androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0 androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.8.0 androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0 androidx.collection:collection-jvm:1.4.2
androidx.collection:collection-ktx:1.4.0 androidx.collection:collection-ktx:1.4.2
androidx.collection:collection:1.4.0 androidx.collection:collection:1.4.2
androidx.compose.animation:animation-android:1.7.0-beta01 androidx.compose.animation:animation-android:1.7.0
androidx.compose.animation:animation-core-android:1.7.0-beta01 androidx.compose.animation:animation-core-android:1.7.0
androidx.compose.animation:animation-core:1.7.0-beta01 androidx.compose.animation:animation-core:1.7.0
androidx.compose.animation:animation:1.7.0-beta01 androidx.compose.animation:animation:1.7.0
androidx.compose.foundation:foundation-android:1.7.0-beta01 androidx.compose.foundation:foundation-android:1.7.0
androidx.compose.foundation:foundation-layout-android:1.7.0-beta01 androidx.compose.foundation:foundation-layout-android:1.7.0
androidx.compose.foundation:foundation-layout:1.7.0-beta01 androidx.compose.foundation:foundation-layout:1.7.0
androidx.compose.foundation:foundation:1.7.0-beta01 androidx.compose.foundation:foundation:1.7.0
androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01 androidx.compose.material3.adaptive:adaptive-android:1.0.0
androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0-beta01 androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0
androidx.compose.material3.adaptive:adaptive-layout:1.0.0-beta01 androidx.compose.material3.adaptive:adaptive-layout:1.0.0
androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0-beta01 androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0
androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-beta01 androidx.compose.material3.adaptive:adaptive-navigation:1.0.0
androidx.compose.material3.adaptive:adaptive:1.0.0-beta01 androidx.compose.material3.adaptive:adaptive:1.0.0
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01 androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01 androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0
androidx.compose.material3:material3-android:1.3.0-beta01 androidx.compose.material3:material3-android:1.3.0
androidx.compose.material3:material3-window-size-class-android:1.3.0-beta01 androidx.compose.material3:material3-window-size-class-android:1.3.0
androidx.compose.material3:material3-window-size-class:1.3.0-beta01 androidx.compose.material3:material3-window-size-class:1.3.0
androidx.compose.material3:material3:1.3.0-beta01 androidx.compose.material3:material3:1.3.0
androidx.compose.material:material-icons-core-android:1.6.3 androidx.compose.material:material-icons-core-android:1.7.0
androidx.compose.material:material-icons-core:1.6.3 androidx.compose.material:material-icons-core:1.7.0
androidx.compose.material:material-icons-extended-android:1.6.3 androidx.compose.material:material-icons-extended-android:1.7.0
androidx.compose.material:material-icons-extended:1.6.3 androidx.compose.material:material-icons-extended:1.7.0
androidx.compose.material:material-ripple-android:1.7.0-beta01 androidx.compose.material:material-ripple-android:1.7.0
androidx.compose.material:material-ripple:1.7.0-beta01 androidx.compose.material:material-ripple:1.7.0
androidx.compose.runtime:runtime-android:1.7.0-beta01 androidx.compose.runtime:runtime-android:1.7.0
androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01 androidx.compose.runtime:runtime-saveable-android:1.7.0
androidx.compose.runtime:runtime-saveable:1.7.0-beta01 androidx.compose.runtime:runtime-saveable:1.7.0
androidx.compose.runtime:runtime-tracing:1.0.0-beta01 androidx.compose.runtime:runtime-tracing:1.0.0-beta01
androidx.compose.runtime:runtime:1.7.0-beta01 androidx.compose.runtime:runtime:1.7.0
androidx.compose.ui:ui-android:1.7.0-beta01 androidx.compose.ui:ui-android:1.7.0
androidx.compose.ui:ui-geometry-android:1.7.0-beta01 androidx.compose.ui:ui-geometry-android:1.7.0
androidx.compose.ui:ui-geometry:1.7.0-beta01 androidx.compose.ui:ui-geometry:1.7.0
androidx.compose.ui:ui-graphics-android:1.7.0-beta01 androidx.compose.ui:ui-graphics-android:1.7.0
androidx.compose.ui:ui-graphics:1.7.0-beta01 androidx.compose.ui:ui-graphics:1.7.0
androidx.compose.ui:ui-text-android:1.7.0-beta01 androidx.compose.ui:ui-text-android:1.7.0
androidx.compose.ui:ui-text:1.7.0-beta01 androidx.compose.ui:ui-text:1.7.0
androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01 androidx.compose.ui:ui-tooling-preview-android:1.7.0
androidx.compose.ui:ui-tooling-preview:1.7.0-beta01 androidx.compose.ui:ui-tooling-preview:1.7.0
androidx.compose.ui:ui-unit-android:1.7.0-beta01 androidx.compose.ui:ui-unit-android:1.7.0
androidx.compose.ui:ui-unit:1.7.0-beta01 androidx.compose.ui:ui-unit:1.7.0
androidx.compose.ui:ui-util-android:1.7.0-beta01 androidx.compose.ui:ui-util-android:1.7.0
androidx.compose.ui:ui-util:1.7.0-beta01 androidx.compose.ui:ui-util:1.7.0
androidx.compose.ui:ui:1.7.0-beta01 androidx.compose.ui:ui:1.7.0
androidx.compose:compose-bom:2024.02.02 androidx.compose:compose-bom:2024.09.00
androidx.concurrent:concurrent-futures:1.1.0 androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1 androidx.core:core-ktx:1.13.1
androidx.core:core-splashscreen:1.0.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.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04 androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.navigation:navigation-common-ktx:2.8.0-alpha06 androidx.navigation:navigation-common-ktx:2.8.0
androidx.navigation:navigation-common:2.8.0-alpha06 androidx.navigation:navigation-common:2.8.0
androidx.navigation:navigation-compose:2.8.0-alpha06 androidx.navigation:navigation-compose:2.8.0
androidx.navigation:navigation-runtime-ktx:2.8.0-alpha06 androidx.navigation:navigation-runtime-ktx:2.8.0
androidx.navigation:navigation-runtime:2.8.0-alpha06 androidx.navigation:navigation-runtime:2.8.0
androidx.print:print:1.0.0 androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05 androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices: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.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0 androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0 androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0-beta02 androidx.window:window-core-android:1.3.0
androidx.window:window-core:1.3.0-beta02 androidx.window:window-core:1.3.0
androidx.window:window:1.3.0-beta02 androidx.window:window:1.3.0
androidx.work:work-runtime-ktx:2.9.0 androidx.work:work-runtime-ktx:2.9.0
androidx.work:work-runtime:2.9.0 androidx.work:work-runtime:2.9.0
com.caverock:androidsvg-aar:1.4 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 io.coil-kt:coil:2.6.0
javax.inject:javax.inject:1 javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0 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-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8: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-android:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0

@ -47,9 +47,13 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<data <action android:name="android.intent.action.VIEW" />
android:scheme="https"
android:host="www.nowinandroid.apps.samples.google.com" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="www.nowinandroid.apps.samples.google.com" />
</intent-filter> </intent-filter>
</activity> </activity>

@ -23,7 +23,6 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -55,8 +54,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "MainActivity"
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -148,7 +145,6 @@ class MainActivity : ComponentActivity() {
androidTheme = shouldUseAndroidTheme(uiState), androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState), disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) { ) {
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
NiaApp(appState) NiaApp(appState)
} }
} }

@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen 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.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
@ -40,12 +40,11 @@ fun NiaNavHost(
appState: NiaAppState, appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = FOR_YOU_ROUTE,
) { ) {
val navController = appState.navController val navController = appState.navController
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = ForYouRoute,
modifier = modifier, modifier = modifier,
) { ) {
forYouScreen(onTopicClick = navController::navigateToInterests) forYouScreen(onTopicClick = navController::navigateToInterests)

@ -16,9 +16,14 @@
package com.google.samples.apps.nowinandroid.navigation package com.google.samples.apps.nowinandroid.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons 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.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.R as searchR 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( enum class TopLevelDestination(
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
val unselectedIcon: ImageVector, val unselectedIcon: ImageVector,
val iconTextId: Int, @StringRes val iconTextId: Int,
val titleTextId: Int, @StringRes val titleTextId: Int,
val route: KClass<*>,
) { ) {
FOR_YOU( FOR_YOU(
selectedIcon = NiaIcons.Upcoming, selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder, unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title, iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name, titleTextId = R.string.app_name,
route = ForYouRoute::class,
), ),
BOOKMARKS( BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks, selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder, unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title, iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title, titleTextId = bookmarksR.string.feature_bookmarks_title,
route = BookmarksRoute::class,
), ),
INTERESTS( INTERESTS(
selectedIcon = NiaIcons.Grid3x3, selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3, unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests, iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests, titleTextId = searchR.string.feature_search_interests,
route = InterestsRoute::class,
), ),
} }

@ -60,6 +60,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground 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.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@ -150,7 +152,7 @@ internal fun NiaApp(
appState.topLevelDestinations.forEach { destination -> appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination) val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination val selected = currentDestination
.isTopLevelDestinationInHierarchy(destination) .isRouteInHierarchy(destination.route)
item( item(
selected = selected, selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) }, onClick = { appState.navigateToTopLevelDestination(destination) },
@ -198,8 +200,10 @@ internal fun NiaApp(
) { ) {
// Show the top app bar on top level destinations. // Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination val destination = appState.currentTopLevelDestination
val shouldShowTopAppBar = destination != null var shouldShowTopAppBar = false
if (destination != null) { if (destination != null) {
shouldShowTopAppBar = true
NiaTopAppBar( NiaTopAppBar(
titleRes = destination.titleTextId, titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search, 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 { this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false it.hasRoute(route)
} ?: false } ?: false

@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState 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.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor 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.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.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.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.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -90,11 +88,10 @@ class NiaAppState(
.currentBackStackEntryAsState().value?.destination .currentBackStackEntryAsState().value?.destination
val currentTopLevelDestination: TopLevelDestination? val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) { @Composable get() {
FOR_YOU_ROUTE -> FOR_YOU return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
BOOKMARKS_ROUTE -> BOOKMARKS currentDestination?.hasRoute(route = topLevelDestination.route) ?: false
INTERESTS_ROUTE -> INTERESTS }
else -> null
} }
val isOffline = networkMonitor.isOnline val isOffline = networkMonitor.isOnline

@ -18,19 +18,26 @@ package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
const val TOPIC_ID_KEY = "selectedTopicId"
@HiltViewModel @HiltViewModel
class Interests2PaneViewModel @Inject constructor( class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle, private val savedStateHandle: SavedStateHandle,
) : ViewModel() { ) : ViewModel() {
val selectedTopicId: StateFlow<String?> =
savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG]) val route = savedStateHandle.toRoute<InterestsRoute>()
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(
key = TOPIC_ID_KEY,
initialValue = route.initialTopicId,
)
fun onTopicClick(topicId: String?) { fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId savedStateHandle[TOPIC_ID_KEY] = topicId
} }
} }

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.ui.interests2pane package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.Keep
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
@ -39,34 +40,26 @@ import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController 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.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder 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.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.createTopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import kotlinx.serialization.Serializable
import java.util.UUID 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() { fun NavGraphBuilder.interestsListDetailScreen() {
composable( composable<InterestsRoute> {
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
type = NavType.StringType
defaultValue = null
nullable = true
},
),
) {
InterestsListDetailScreen() InterestsListDetailScreen()
} }
} }
@ -104,8 +97,9 @@ internal fun InterestsListDetailScreen(
listDetailNavigator.navigateBack() listDetailNavigator.navigateBack()
} }
var nestedNavHostStartDestination by remember { var nestedNavHostStartRoute by remember {
mutableStateOf(selectedTopicId?.let(::createTopicRoute) ?: TOPIC_ROUTE) val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
mutableStateOf(route)
} }
var nestedNavKey by rememberSaveable( var nestedNavKey by rememberSaveable(
stateSaver = Saver({ it.toString() }, UUID::fromString), 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 // If the detail pane was visible, then use the nestedNavController navigate call
// directly // directly
nestedNavController.navigateToTopic(topicId) { nestedNavController.navigateToTopic(topicId) {
popUpTo(DETAIL_PANE_NAVHOST_ROUTE) popUpTo<DetailPaneNavHostRoute>()
} }
} else { } else {
// Otherwise, recreate the NavHost entirely, and start at the new destination // Otherwise, recreate the NavHost entirely, and start at the new destination
nestedNavHostStartDestination = createTopicRoute(topicId) nestedNavHostStartRoute = TopicRoute(id = topicId)
nestedNavKey = UUID.randomUUID() nestedNavKey = UUID.randomUUID()
} }
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
@ -148,15 +142,15 @@ internal fun InterestsListDetailScreen(
key(nestedNavKey) { key(nestedNavKey) {
NavHost( NavHost(
navController = nestedNavController, navController = nestedNavController,
startDestination = nestedNavHostStartDestination, startDestination = nestedNavHostStartRoute,
route = DETAIL_PANE_NAVHOST_ROUTE, route = DetailPaneNavHostRoute::class,
) { ) {
topicScreen( topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(), showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack, onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane, onTopicClick = ::onTopicClickShowDetailPane,
) )
composable(route = TOPIC_ROUTE) { composable<TopicPlaceholderRoute> {
TopicDetailPlaceholder() TopicDetailPlaceholder()
} }
} }

@ -17,18 +17,17 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import android.view.WindowInsets import android.view.WindowInsets
import android.widget.FrameLayout import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState 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.test.DeviceConfigurationOverride
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children import androidx.core.view.children
/** /**
* A [DeviceConfigurationOverride] that allows overriding the [windowInsets] available * A [DeviceConfigurationOverride] that overrides the window insets for the contained content.
* to the content under test.
*/ */
@Suppress("ktlint:standard:function-naming") @Suppress("ktlint:standard:function-naming")
fun DeviceConfigurationOverride.Companion.WindowInsets( fun DeviceConfigurationOverride.Companion.WindowInsets(
@ -38,10 +37,17 @@ fun DeviceConfigurationOverride.Companion.WindowInsets(
val currentWindowInsets by rememberUpdatedState(windowInsets) val currentWindowInsets by rememberUpdatedState(windowInsets)
AndroidView( AndroidView(
factory = { context -> factory = { context ->
object : FrameLayout(context) { object : AbstractComposeView(context) {
@Composable
override fun Content() {
currentContentUnderTest()
}
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
children.forEach { children.forEach {
it.dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()) it.dispatchApplyWindowInsets(
WindowInsets(currentWindowInsets.toWindowInsets()),
)
} }
return WindowInsetsCompat.CONSUMED.toWindowInsets()!! return WindowInsetsCompat.CONSUMED.toWindowInsets()!!
} }
@ -52,17 +58,10 @@ fun DeviceConfigurationOverride.Companion.WindowInsets(
*/ */
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun requestFitSystemWindows() { override fun requestFitSystemWindows() {
dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()!!) dispatchApplyWindowInsets(WindowInsets(currentWindowInsets.toWindowInsets()!!))
} }
}.apply {
addView(
ComposeView(context).apply {
setContent {
currentContentUnderTest()
}
},
)
} }
}, },
update = { with(currentWindowInsets) { it.requestApplyInsets() } },
) )
} }

@ -14,43 +14,49 @@
* limitations under the License. * 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.activity.compose.BackHandler
import androidx.compose.material3.adaptive.Posture import androidx.annotation.StringRes
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.test.ForcedSize
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed 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.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick 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.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.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme 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.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 com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.inject.Inject import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty
import kotlin.test.assertTrue import kotlin.test.assertTrue
import com.google.samples.apps.nowinandroid.feature.topic.R as FeatureTopicR 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 @HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
class InterestsListDetailScreenTest { class InterestsListDetailScreenTest {
@get:Rule(order = 0) @get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this) val hiltRule = HiltAndroidRule(this)
@ -64,6 +70,11 @@ class InterestsListDetailScreenTest {
@Inject @Inject
lateinit var topicsRepository: TopicsRepository lateinit var topicsRepository: TopicsRepository
/** Convenience function for getting all topics during tests, */
private fun getTopics(): List<Topic> = runBlocking {
topicsRepository.getTopics().first().sortedBy { it.name }
}
// The strings used for matching in these tests. // The strings used for matching in these tests.
private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest) private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest)
private val listPaneTag = "interests:topics" private val listPaneTag = "interests:topics"
@ -71,39 +82,18 @@ class InterestsListDetailScreenTest {
private val Topic.testTag private val Topic.testTag
get() = "topic:${this.id}" 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 @Before
fun setup() { fun setup() {
hiltRule.inject() hiltRule.inject()
} }
/** Convenience function for getting all topics during tests, */
private fun getTopics(): List<Topic> = runBlocking {
topicsRepository.getTopics().first()
}
@Test @Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() { fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() {
composeTestRule.apply { composeTestRule.apply {
setContent { setContent {
with(TestDeviceConfig.Expanded) { NiaTheme {
DeviceConfigurationOverride(override = sizeOverride) { InterestsListDetailScreen()
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
} }
} }
@ -113,15 +103,12 @@ class InterestsListDetailScreenTest {
} }
@Test @Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_initialState_showsListPane() { fun compactWidth_initialState_showsListPane() {
composeTestRule.apply { composeTestRule.apply {
setContent { setContent {
with(TestDeviceConfig.Compact) { NiaTheme {
DeviceConfigurationOverride(override = sizeOverride) { InterestsListDetailScreen()
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
} }
} }
@ -131,15 +118,12 @@ class InterestsListDetailScreenTest {
} }
@Test @Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_topicSelected_updatesDetailPane() { fun expandedWidth_topicSelected_updatesDetailPane() {
composeTestRule.apply { composeTestRule.apply {
setContent { setContent {
with(TestDeviceConfig.Expanded) { NiaTheme {
DeviceConfigurationOverride(override = sizeOverride) { InterestsListDetailScreen()
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
} }
} }
@ -153,15 +137,12 @@ class InterestsListDetailScreenTest {
} }
@Test @Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_topicSelected_showsTopicDetailPane() { fun compactWidth_topicSelected_showsTopicDetailPane() {
composeTestRule.apply { composeTestRule.apply {
setContent { setContent {
with(TestDeviceConfig.Compact) { NiaTheme {
DeviceConfigurationOverride(override = sizeOverride) { InterestsListDetailScreen()
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
} }
} }
@ -175,27 +156,25 @@ class InterestsListDetailScreenTest {
} }
@Test @Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_backPressFromTopicDetail_leavesInterests() { fun expandedWidth_backPressFromTopicDetail_leavesInterests() {
var unhandledBackPress = false var unhandledBackPress = false
composeTestRule.apply { composeTestRule.apply {
setContent { setContent {
with(TestDeviceConfig.Expanded) { NiaTheme {
DeviceConfigurationOverride(override = sizeOverride) { // Back press should not be handled by the two pane layout, and thus
NiaTheme { // "fall through" to this BackHandler.
// Back press should not be handled by the two pane layout, and thus BackHandler {
// "fall through" to this BackHandler. unhandledBackPress = true
BackHandler {
unhandledBackPress = true
}
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
} }
InterestsListDetailScreen()
} }
} }
val firstTopic = getTopics().first() val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick() onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack() Espresso.pressBack()
assertTrue(unhandledBackPress) assertTrue(unhandledBackPress)
@ -203,21 +182,19 @@ class InterestsListDetailScreenTest {
} }
@Test @Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_backPressFromTopicDetail_showsListPane() { fun compactWidth_backPressFromTopicDetail_showsListPane() {
composeTestRule.apply { composeTestRule.apply {
setContent { setContent {
with(TestDeviceConfig.Compact) { NiaTheme {
DeviceConfigurationOverride(override = sizeOverride) { InterestsListDetailScreen()
NiaTheme {
InterestsListDetailScreen(windowAdaptiveInfo = adaptiveInfo)
}
}
} }
} }
val firstTopic = getTopics().first() val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick() onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack() Espresso.pressBack()
onNodeWithTag(listPaneTag).assertIsDisplayed() onNodeWithTag(listPaneTag).assertIsDisplayed()
@ -226,3 +203,8 @@ class InterestsListDetailScreenTest {
} }
} }
} }
private fun AndroidComposeTestRule<*, *>.stringResource(
@StringRes resId: Int,
): ReadOnlyProperty<Any, String> =
ReadOnlyProperty { _, _ -> activity.getString(resId) }

@ -28,6 +28,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
pluginManager.apply { pluginManager.apply {
apply("nowinandroid.android.library") apply("nowinandroid.android.library")
apply("nowinandroid.hilt") apply("nowinandroid.hilt")
apply("org.jetbrains.kotlin.plugin.serialization")
} }
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
testOptions.animationsDisabled = true testOptions.animationsDisabled = true
@ -41,8 +42,11 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get()) add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get()) add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").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("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()) add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
} }
} }

@ -65,8 +65,7 @@ internal fun Project.configureAndroidCompose(
.relativeToRootProject("compose-reports") .relativeToRootProject("compose-reports")
.let(reportsDestination::set) .let(reportsDestination::set)
stabilityConfigurationFile = rootProject.layout.projectDirectory.file("compose_compiler_config.conf") stabilityConfigurationFile =
rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
enableStrongSkippingMode = true
} }
} }

@ -35,12 +35,12 @@ import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized
import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.register
import org.gradle.language.base.plugins.LifecycleBasePlugin import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.process.ExecOperations import org.gradle.process.ExecOperations
import java.io.File import java.io.File
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@CacheableTask @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( fun Project.configureBadgingTasks(
baseExtension: BaseExtension, baseExtension: BaseExtension,
componentsExtension: ApplicationAndroidComponentsExtension, componentsExtension: ApplicationAndroidComponentsExtension,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 290 B

@ -22,6 +22,7 @@ import kotlinx.datetime.Instant
* A [NewsResource] with additional user information such as whether the user is following the * 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. * news resource's topics and whether they have saved (bookmarked) this news resource.
*/ */
@ConsistentCopyVisibility
data class UserNewsResource internal constructor( data class UserNewsResource internal constructor(
val id: String, val id: String,
val title: String, val title: String,

@ -44,7 +44,10 @@ private const val NEWS_NOTIFICATION_SUMMARY_ID = 1
private const val NEWS_NOTIFICATION_CHANNEL_ID = "" private const val NEWS_NOTIFICATION_CHANNEL_ID = ""
private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS" 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 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. * 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, 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()

@ -87,7 +87,7 @@ fun LazyStaggeredGridScope.newsFeed(
onTopicClick = onTopicClick, onTopicClick = onTopicClick,
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.animateItemPlacement(), .animateItem(),
) )
} }
} }

@ -16,8 +16,15 @@
package com.google.samples.apps.nowinandroid.core.ui 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.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image 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.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -45,6 +52,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropTransferData
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
@ -77,6 +85,7 @@ import java.util.Locale
* [NewsResource] card used on the following screens: For You, Saved * [NewsResource] card used on the following screens: For You, Saved
*/ */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun NewsResourceCardExpanded( fun NewsResourceCardExpanded(
userNewsResource: UserNewsResource, userNewsResource: UserNewsResource,
@ -88,6 +97,19 @@ fun NewsResourceCardExpanded(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val clickActionLabel = stringResource(R.string.core_ui_card_tap_action) 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( Card(
onClick = onClick, onClick = onClick,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
@ -112,7 +134,23 @@ fun NewsResourceCardExpanded(
Row { Row {
NewsResourceTitle( NewsResourceTitle(
userNewsResource.title, 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)) Spacer(modifier = Modifier.weight(1f))
BookmarkButton(isBookmarked, onToggleBookmark) BookmarkButton(isBookmarked, onToggleBookmark)

@ -29,4 +29,6 @@
<string name="core_ui_interests_card_follow_button_content_desc">Follow interest</string> <string name="core_ui_interests_card_follow_button_content_desc">Follow interest</string>
<string name="core_ui_interests_card_unfollow_button_content_desc">Unfollow interest</string> <string name="core_ui_interests_card_unfollow_button_content_desc">Unfollow interest</string>
<string name="core_ui_feed_sharing">Feed sharing</string>
<string name="core_ui_feed_sharing_data">%1$s: %2$s</string>
</resources> </resources>

@ -21,16 +21,18 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute 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( fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
) { ) {
composable(route = BOOKMARKS_ROUTE) { composable<BookmarksRoute> {
BookmarksRoute(onTopicClick, onShowSnackbar) BookmarksRoute(onTopicClick, onShowSnackbar)
} }
} }

@ -29,6 +29,7 @@ dependencies {
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(projects.core.data) implementation(projects.core.data)
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(project(":core:notifications"))
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)

@ -106,7 +106,7 @@ import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab
import com.google.samples.apps.nowinandroid.core.ui.newsFeed import com.google.samples.apps.nowinandroid.core.ui.newsFeed
@Composable @Composable
internal fun ForYouRoute( internal fun ForYouScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel(), viewModel: ForYouViewModel = hiltViewModel(),

@ -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.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.SyncManager 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.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.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -55,7 +55,7 @@ class ForYouViewModel @Inject constructor(
userDataRepository.userData.map { !it.shouldHideOnboarding } userDataRepository.userData.map { !it.shouldHideOnboarding }
val deepLinkedNewsResource = savedStateHandle.getStateFlow<String?>( val deepLinkedNewsResource = savedStateHandle.getStateFlow<String?>(
key = LINKED_NEWS_RESOURCE_ID, key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
null, null,
) )
.flatMapLatest { newsResourceId -> .flatMapLatest { newsResourceId ->
@ -129,7 +129,7 @@ class ForYouViewModel @Inject constructor(
fun onDeepLinkOpened(newsResourceId: String) { fun onDeepLinkOpened(newsResourceId: String) {
if (newsResourceId == deepLinkedNewsResource.value?.id) { if (newsResourceId == deepLinkedNewsResource.value?.id) {
savedStateHandle[LINKED_NEWS_RESOURCE_ID] = null savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = null
} }
analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId) analyticsHelper.logNewsDeepLinkOpen(newsResourceId = newsResourceId)
viewModelScope.launch { viewModelScope.launch {
@ -153,7 +153,7 @@ private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) =
type = "news_deep_link_opened", type = "news_deep_link_opened",
extras = listOf( extras = listOf(
Param( Param(
key = LINKED_NEWS_RESOURCE_ID, key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = newsResourceId, value = newsResourceId,
), ),
), ),

@ -19,29 +19,31 @@ package com.google.samples.apps.nowinandroid.feature.foryou.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink 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" @Serializable data object ForYouRoute
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}"
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) { fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) {
composable( composable<ForYouRoute>(
route = FOR_YOU_ROUTE,
deepLinks = listOf( deepLinks = listOf(
navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN }, navDeepLink {
), /**
arguments = listOf( * This destination has a deep link that enables a specific news resource to be
navArgument(LINKED_NEWS_RESOURCE_ID) { type = NavType.StringType }, * 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)
} }
} }

@ -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.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource 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.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.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository 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.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.TestAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager 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.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.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -472,7 +472,7 @@ class ForYouViewModelTest {
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(emptyUserData) userDataRepository.setUserData(emptyUserData)
savedStateHandle[LINKED_NEWS_RESOURCE_ID] = sampleNewsResources.first().id savedStateHandle[DEEP_LINK_NEWS_RESOURCE_ID_KEY] = sampleNewsResources.first().id
assertEquals( assertEquals(
expected = UserNewsResource( expected = UserNewsResource(
@ -496,7 +496,7 @@ class ForYouViewModelTest {
type = "news_deep_link_opened", type = "news_deep_link_opened",
extras = listOf( extras = listOf(
Param( Param(
key = LINKED_NEWS_RESOURCE_ID, key = DEEP_LINK_NEWS_RESOURCE_ID_KEY,
value = sampleNewsResources.first().id, value = sampleNewsResources.first().id,
), ),
), ),

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.domain) implementation(projects.core.domain)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing) androidTestImplementation(projects.core.testing)

@ -19,11 +19,12 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase 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.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -39,7 +40,14 @@ class InterestsViewModel @Inject constructor(
getFollowableTopics: GetFollowableTopicsUseCase, getFollowableTopics: GetFollowableTopicsUseCase,
) : ViewModel() { ) : ViewModel() {
val selectedTopicId: StateFlow<String?> = 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<InterestsUiState> = combine( val uiState: StateFlow<InterestsUiState> = combine(
selectedTopicId, selectedTopicId,
@ -58,7 +66,7 @@ class InterestsViewModel @Inject constructor(
} }
fun onTopicClick(topicId: String?) { fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId savedStateHandle[selectedTopicIdKey] = topicId
} }
} }

@ -17,39 +17,17 @@
package com.google.samples.apps.nowinandroid.feature.interests.navigation package com.google.samples.apps.nowinandroid.feature.interests.navigation
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.NavType import kotlinx.serialization.Serializable
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
const val TOPIC_ID_ARG = "topicId" @Serializable data class InterestsRoute(
const val INTERESTS_ROUTE_BASE = "interests_route" // The ID of the topic which will be initially selected at this destination
const val INTERESTS_ROUTE = "$INTERESTS_ROUTE_BASE?$TOPIC_ID_ARG={$TOPIC_ID_ARG}" val initialTopicId: String? = null,
)
fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOptions? = null) { fun NavController.navigateToInterests(
val route = if (topicId != null) { initialTopicId: String? = null,
"${INTERESTS_ROUTE_BASE}?${TOPIC_ID_ARG}=$topicId" navOptions: NavOptions? = null,
} else {
INTERESTS_ROUTE_BASE
}
navigate(route, navOptions)
}
fun NavGraphBuilder.interestsScreen(
onTopicClick: (String) -> Unit,
) { ) {
composable( navigate(route = InterestsRoute(initialTopicId), navOptions)
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
defaultValue = null
nullable = true
type = NavType.StringType
},
),
) {
InterestsRoute(onTopicClick = onTopicClick)
}
} }

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.interests
import androidx.lifecycle.SavedStateHandle 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.domain.GetFollowableTopicsUseCase
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.Topic 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.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState 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.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.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -33,12 +34,21 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals import kotlin.test.assertEquals
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein * 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 { class InterestsViewModelTest {
@get:Rule @get:Rule
@ -55,7 +65,9 @@ class InterestsViewModelTest {
@Before @Before
fun setup() { fun setup() {
viewModel = InterestsViewModel( viewModel = InterestsViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)), savedStateHandle = SavedStateHandle(
route = InterestsRoute(initialTopicId = testInputTopics[0].topic.id),
),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
getFollowableTopics = getFollowableTopicsUseCase, getFollowableTopics = getFollowableTopicsUseCase,
) )

@ -27,7 +27,6 @@ android {
dependencies { dependencies {
implementation(projects.core.data) implementation(projects.core.data)
implementation(projects.core.domain) implementation(projects.core.domain)
implementation(projects.core.ui)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)

@ -41,7 +41,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon 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.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString 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.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@ -227,23 +228,31 @@ fun EmptySearchResultBody(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 24.dp), modifier = Modifier.padding(vertical = 24.dp),
) )
val interests = stringResource(id = searchR.string.feature_search_interests)
val tryAnotherSearchString = buildAnnotatedString { val tryAnotherSearchString = buildAnnotatedString {
append(stringResource(id = searchR.string.feature_search_try_another_search)) append(stringResource(id = searchR.string.feature_search_try_another_search))
append(" ") append(" ")
withStyle( withLink(
style = SpanStyle( LinkAnnotation.Clickable(
textDecoration = TextDecoration.Underline, tag = "",
fontWeight = FontWeight.Bold, linkInteractionListener = {
onInterestsClick()
},
), ),
) { ) {
pushStringAnnotation(tag = interests, annotation = interests) withStyle(
append(interests) style = SpanStyle(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Bold,
),
) {
append(stringResource(id = searchR.string.feature_search_interests))
}
} }
append(" ") append(" ")
append(stringResource(id = searchR.string.feature_search_to_browse_topics)) append(stringResource(id = searchR.string.feature_search_to_browse_topics))
} }
ClickableText( Text(
text = tryAnotherSearchString, text = tryAnotherSearchString,
style = MaterialTheme.typography.bodyLarge.merge( style = MaterialTheme.typography.bodyLarge.merge(
TextStyle( TextStyle(
@ -252,13 +261,8 @@ fun EmptySearchResultBody(
), ),
), ),
modifier = Modifier modifier = Modifier
.padding(start = 36.dp, end = 36.dp, bottom = 24.dp) .padding(start = 36.dp, end = 36.dp, bottom = 24.dp),
.clickable {}, )
) { offset ->
tryAnotherSearchString.getStringAnnotations(start = offset, end = offset)
.firstOrNull()
?.let { onInterestsClick() }
}
} }
} }

@ -28,6 +28,7 @@ dependencies {
implementation(projects.core.data) implementation(projects.core.data)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)
testImplementation(libs.robolectric)
androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing) androidTestImplementation(projects.core.testing)

@ -71,7 +71,7 @@ import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems
import com.google.samples.apps.nowinandroid.feature.topic.R.string import com.google.samples.apps.nowinandroid.feature.topic.R.string
@Composable @Composable
internal fun TopicRoute( internal fun TopicScreen(
showBackButton: Boolean, showBackButton: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository 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.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -47,12 +48,10 @@ class TopicViewModel @Inject constructor(
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() { ) : ViewModel() {
private val topicArgs: TopicArgs = TopicArgs(savedStateHandle) val topicId = savedStateHandle.toRoute<TopicRoute>().id
val topicId = topicArgs.topicId
val topicUiState: StateFlow<TopicUiState> = topicUiState( val topicUiState: StateFlow<TopicUiState> = topicUiState(
topicId = topicArgs.topicId, topicId = topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
) )
@ -63,7 +62,7 @@ class TopicViewModel @Inject constructor(
) )
val newsUiState: StateFlow<NewsUiState> = newsUiState( val newsUiState: StateFlow<NewsUiState> = newsUiState(
topicId = topicArgs.topicId, topicId = topicId,
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
) )
@ -75,7 +74,7 @@ class TopicViewModel @Inject constructor(
fun followTopicToggle(followed: Boolean) { fun followTopicToggle(followed: Boolean) {
viewModelScope.launch { viewModelScope.launch {
userDataRepository.setTopicIdFollowed(topicArgs.topicId, followed) userDataRepository.setTopicIdFollowed(topicId, followed)
} }
} }

@ -16,53 +16,28 @@
package com.google.samples.apps.nowinandroid.feature.topic.navigation package com.google.samples.apps.nowinandroid.feature.topic.navigation
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen
import com.google.samples.apps.nowinandroid.feature.topic.TopicRoute import kotlinx.serialization.Serializable
import java.net.URLDecoder
import java.net.URLEncoder
import kotlin.text.Charsets.UTF_8
private val URL_CHARACTER_ENCODING = UTF_8.name() @Serializable data class TopicRoute(val id: String)
@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))
}
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {
navigate(createTopicRoute(topicId)) { navigate(route = TopicRoute(topicId)) {
navOptions() navOptions()
} }
} }
fun createTopicRoute(topicId: String): String {
val encodedId = URLEncoder.encode(topicId, URL_CHARACTER_ENCODING)
return "$TOPIC_ROUTE/$encodedId"
}
fun NavGraphBuilder.topicScreen( fun NavGraphBuilder.topicScreen(
showBackButton: Boolean, showBackButton: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
) { ) {
composable( composable<TopicRoute> {
route = "topic_route/{$TOPIC_ID_ARG}", TopicScreen(
arguments = listOf(
navArgument(TOPIC_ID_ARG) { type = NavType.StringType },
),
) {
TopicRoute(
showBackButton = showBackButton, showBackButton = showBackButton,
onBackClick = onBackClick, onBackClick = onBackClick,
onTopicClick = onTopicClick, onTopicClick = onTopicClick,

@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.topic package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle 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.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource 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.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository 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.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.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -36,13 +37,22 @@ import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs import kotlin.test.assertIs
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein * 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 { class TopicViewModelTest {
@get:Rule @get:Rule
@ -60,7 +70,9 @@ class TopicViewModelTest {
@Before @Before
fun setup() { fun setup() {
viewModel = TopicViewModel( viewModel = TopicViewModel(
savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)), savedStateHandle = SavedStateHandle(
route = TopicRoute(id = testInputTopics[0].topic.id),
),
userDataRepository = userDataRepository, userDataRepository = userDataRepository,
topicsRepository = topicsRepository, topicsRepository = topicsRepository,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,

@ -2,15 +2,12 @@
accompanist = "0.34.0" accompanist = "0.34.0"
androidDesugarJdkLibs = "2.0.4" androidDesugarJdkLibs = "2.0.4"
# AGP and tools should be updated together # AGP and tools should be updated together
androidGradlePlugin = "8.4.0" androidGradlePlugin = "8.6.0"
androidTools = "31.4.1" androidTools = "31.6.0"
androidxActivity = "1.8.2" androidxActivity = "1.8.2"
androidxAppCompat = "1.7.0" androidxAppCompat = "1.7.0"
androidxBrowser = "1.8.0" androidxBrowser = "1.8.0"
androidxComposeAlpha = "1.7.0-beta01" androidxComposeBom = "2024.09.00"
androidxComposeBom = "2024.02.02"
androidxComposeMaterial3Adaptive = "1.0.0-beta01"
androidxComposeMaterial3AdaptiveNavigationSuite = "1.3.0-beta01"
androidxComposeRuntimeTracing = "1.0.0-beta01" androidxComposeRuntimeTracing = "1.0.0-beta01"
androidxCore = "1.12.0" androidxCore = "1.12.0"
androidxCoreSplashscreen = "1.0.1" androidxCoreSplashscreen = "1.0.1"
@ -20,7 +17,7 @@ androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.8.3" androidxLifecycle = "2.8.3"
androidxMacroBenchmark = "1.2.4" androidxMacroBenchmark = "1.2.4"
androidxMetrics = "1.0.0-alpha04" androidxMetrics = "1.0.0-alpha04"
androidxNavigation = "2.8.0-alpha06" androidxNavigation = "2.8.0"
androidxProfileinstaller = "1.3.1" androidxProfileinstaller = "1.3.1"
androidxTestCore = "1.5.0" androidxTestCore = "1.5.0"
androidxTestExt = "1.1.5" androidxTestExt = "1.1.5"
@ -28,7 +25,7 @@ androidxTestRules = "1.5.0"
androidxTestRunner = "1.5.2" androidxTestRunner = "1.5.2"
androidxTracing = "1.3.0-alpha02" androidxTracing = "1.3.0-alpha02"
androidxUiAutomator = "2.3.0" androidxUiAutomator = "2.3.0"
androidxWindowManager = "1.3.0-alpha03" androidxWindowManager = "1.3.0"
androidxWork = "2.9.0" androidxWork = "2.9.0"
coil = "2.6.0" coil = "2.6.0"
dependencyGuard = "0.5.0" dependencyGuard = "0.5.0"
@ -42,11 +39,11 @@ hilt = "2.51.1"
hiltExt = "1.1.0" hiltExt = "1.1.0"
jacoco = "0.8.7" jacoco = "0.8.7"
junit4 = "4.13.2" junit4 = "4.13.2"
kotlin = "2.0.0" kotlin = "2.0.20"
kotlinxCoroutines = "1.8.0" kotlinxCoroutines = "1.8.0"
kotlinxDatetime = "0.5.0" kotlinxDatetime = "0.5.0"
kotlinxSerializationJson = "1.6.3" kotlinxSerializationJson = "1.6.3"
ksp = "2.0.0-1.0.21" ksp = "2.0.20-1.0.24"
moduleGraph = "2.5.0" moduleGraph = "2.5.0"
okhttp = "4.12.0" okhttp = "4.12.0"
protobuf = "4.26.1" 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-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "androidxMacroBenchmark" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } 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-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" }
androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } 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-navigationSuite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "androidxComposeMaterial3Adaptive" } androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" }
androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "androidxComposeMaterial3Adaptive" } 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", version.ref = "androidxComposeMaterial3Adaptive" } 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-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" }
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } 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-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-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 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" } hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" }
javax-inject = { module = "javax.inject:javax.inject", version = "1" } javax-inject = { module = "javax.inject:javax.inject", version = "1" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk8", version.ref = "kotlin" } 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-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-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" } kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines" }

Binary file not shown.

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

7
gradlew vendored

@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# #
# SPDX-License-Identifier: Apache-2.0
#
############################################################################## ##############################################################################
# #
@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop. # Darwin, MinGW, and NonStop.
# #
# (3) This script is generated from the Groovy template # (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. # within the Gradle project.
# #
# You can find Gradle at https://github.com/gradle/gradle/. # You can find Gradle at https://github.com/gradle/gradle/.
@ -84,7 +86,8 @@ done
# shellcheck disable=SC2034 # shellcheck disable=SC2034
APP_BASE_NAME=${0##*/} APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) # 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. # Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum MAX_FD=maximum

2
gradlew.bat vendored

@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################

Loading…
Cancel
Save