Merge remote-tracking branch 'origin/main' into in-memory-datastore

pull/1542/head
Simon Marquis 12 months ago
commit 8ca16d85a7

@ -25,6 +25,13 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Copy CI gradle.properties - name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
@ -35,10 +42,13 @@ 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:
validate-wrappers: true - name: Setup Android SDK
gradle-home-cache-cleanup: true uses: android-actions/setup-android@v3
- name: Accept licenses
run: yes | sdkmanager --licenses || true
- name: Check build-logic - name: Check build-logic
run: ./gradlew check -p build-logic run: ./gradlew check -p build-logic
@ -101,19 +111,22 @@ jobs:
commit_message: "🤖 Updates screenshots" commit_message: "🤖 Updates screenshots"
# Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots. # Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots.
- name: Run local tests and create report - name: Run local tests
if: always()
run: ./gradlew testDemoDebug :lint:test run: ./gradlew testDemoDebug :lint:test
# Replace task exclusions with `-Pandroidx.baselineprofile.skipgeneration` when
# https://android-review.googlesource.com/c/platform/frameworks/support/+/2602790 landed in a - name: Setup GMD
# release build run: ./gradlew :benchmarks:pixel6Api33Setup
--info
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Build all build type and flavor permutations - name: Build all build type and flavor permutations
run: ./gradlew :app:assemble :benchmarks:assemble run: ./gradlew :app:assemble :benchmarks:assemble -Pandroidx.baselineprofile.skipgeneration
-x pixel6Api33ProdNonMinifiedReleaseAndroidTest -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
-x pixel6Api33DemoNonMinifiedReleaseAndroidTest -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-x collectDemoNonMinifiedReleaseBaselineProfile -Pandroid.experimental.androidTest.numManagedDeviceShards=1
-x collectProdNonMinifiedReleaseBaselineProfile -Pandroid.experimental.testOptions.managedDevices.maxConcurrentDevices=1
-Pandroid.experimental.testOptions.managedDevices.setupTimeoutMinutes=5
- name: Upload build outputs (APKs) - name: Upload build outputs (APKs)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -187,7 +200,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 +235,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

@ -0,0 +1,59 @@
name: NightlyBaselineProfiles
on:
schedule:
- cron: '42 4 * * *'
jobs:
baseline_profiles:
name: "Generate Baseline Profiles"
runs-on: ubuntu-latest
permissions:
contents: write
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Accept licenses
run: yes | sdkmanager --licenses || true
- name: Check build-logic
run: ./gradlew check -p build-logic
- name: Setup GMD
run: ./gradlew :benchmarks:pixel6Api33Setup
--info
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Build all build type and flavor permutations including baseline profiles
run: ./gradlew :app:assemble
-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=baselineprofile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true

@ -32,16 +32,19 @@ 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:
validate-wrappers: true - name: Setup Android SDK
gradle-home-cache-cleanup: true uses: android-actions/setup-android@v3
- name: Install GMD image for baseline profile generation - name: Accept licenses
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager "system-images;android-33;aosp_atd;x86_64" run: yes | sdkmanager --licenses || true
- name: Accept Android licenses - name: Setup GMD
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true run: ./gradlew :benchmarks:pixel6Api33Setup
--info
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Build release variant including baseline profile generation - name: Build release variant including baseline profile generation
run: ./gradlew :app:assembleDemoRelease run: ./gradlew :app:assembleDemoRelease

1
.gitignore vendored

@ -13,6 +13,7 @@ bin/
gen/ gen/
out/ out/
build/ build/
generated/
# Local configuration file (sdk path, etc) # Local configuration file (sdk path, etc)
local.properties local.properties

@ -1,2 +1,2 @@
# This file can be used to trigger an internal build by changing the number below # This file can be used to trigger an internal build by changing the number below
3 2

@ -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

@ -1,6 +1,6 @@
androidx.activity:activity-compose:1.8.2 androidx.activity:activity-compose:1.9.2
androidx.activity:activity-ktx:1.8.2 androidx.activity:activity-ktx:1.9.2
androidx.activity:activity:1.8.2 androidx.activity:activity:1.9.2
androidx.annotation:annotation-experimental:1.4.0 androidx.annotation:annotation-experimental:1.4.0
androidx.annotation:annotation-jvm:1.8.0 androidx.annotation:annotation-jvm:1.8.0
androidx.annotation:annotation:1.8.0 androidx.annotation:annotation:1.8.0
@ -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,25 +61,25 @@ 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-beta01
androidx.profileinstaller:profileinstaller:1.3.1 androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1 androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1 androidx.savedstate:savedstate:1.2.1
@ -91,32 +91,33 @@ 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.52
com.google.dagger:dagger:2.51.1 com.google.dagger:dagger:2.52
com.google.dagger:hilt-android:2.51.1 com.google.dagger:hilt-android:2.52
com.google.dagger:hilt-core:2.51.1 com.google.dagger:hilt-core:2.52
com.google.guava:listenablefuture:1.0 com.google.guava:listenablefuture:1.0
com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.8.0 com.squareup.okio:okio-jvm:3.9.0
com.squareup.okio:okio:3.8.0 com.squareup.okio:okio:3.9.0
io.coil-kt:coil-base:2.6.0 io.coil-kt:coil-base:2.7.0
io.coil-kt:coil-compose-base:2.6.0 io.coil-kt:coil-compose-base:2.7.0
io.coil-kt:coil-compose:2.6.0 io.coil-kt:coil-compose:2.7.0
io.coil-kt:coil:2.6.0 io.coil-kt:coil:2.7.0
jakarta.inject:jakarta.inject-api:2.0.1
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.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.5.0 org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1
org.jetbrains.kotlinx:kotlinx-datetime:0.5.0 org.jetbrains.kotlinx:kotlinx-datetime:0.6.1
org.jetbrains:annotations:23.0.0 org.jetbrains:annotations:23.0.0

@ -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 {
@ -47,7 +48,7 @@ android {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
// To publish on the Play store a private signing key is required, but to allow anyone // To publish on the Play store a private signing key is required, but to allow anyone
// who clones the code to sign and run the release variant, use the debug signing key. // who clones the code to sign and run the release variant, use the debug signing key.
@ -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)
@ -115,6 +117,7 @@ dependencies {
testImplementation(projects.core.datastoreTest) testImplementation(projects.core.datastoreTest)
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)
@ -136,6 +139,9 @@ baselineProfile {
// Don't build on every iteration of a full assemble. // Don't build on every iteration of a full assemble.
// Instead enable generation directly for the release build variant. // Instead enable generation directly for the release build variant.
automaticGenerationDuringBuild = false automaticGenerationDuringBuild = false
// Make use of Dex Layout Optimizations via Startup Profiles
dexLayoutOptimization = true
} }
dependencyGuard { dependencyGuard {

@ -1,64 +1,64 @@
androidx.activity:activity-compose:1.8.2 androidx.activity:activity-compose:1.9.2
androidx.activity:activity-ktx:1.8.2 androidx.activity:activity-ktx:1.9.2
androidx.activity:activity:1.8.2 androidx.activity:activity:1.9.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.1
androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01 androidx.compose.runtime:runtime-saveable-android:1.7.1
androidx.compose.runtime:runtime-saveable:1.7.0-beta01 androidx.compose.runtime:runtime-saveable:1.7.1
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.1
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
@ -66,10 +66,16 @@ androidx.core:core:1.13.1
androidx.cursoradapter:cursoradapter:1.0.0 androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0 androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0 androidx.customview:customview:1.0.0
androidx.datastore:datastore-core:1.0.0 androidx.datastore:datastore-android:1.1.1
androidx.datastore:datastore-preferences-core:1.0.0 androidx.datastore:datastore-core-android:1.1.1
androidx.datastore:datastore-preferences:1.0.0 androidx.datastore:datastore-core-okio-jvm:1.1.1
androidx.datastore:datastore:1.0.0 androidx.datastore:datastore-core-okio:1.1.1
androidx.datastore:datastore-core:1.1.1
androidx.datastore:datastore-preferences-android:1.1.1
androidx.datastore:datastore-preferences-core-jvm:1.1.1
androidx.datastore:datastore-preferences-core:1.1.1
androidx.datastore:datastore-preferences:1.1.1
androidx.datastore:datastore:1.1.1
androidx.documentfile:documentfile:1.0.0 androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.0.0 androidx.drawerlayout:drawerlayout:1.0.0
androidx.emoji2:emoji2-views-helper:1.3.0 androidx.emoji2:emoji2-views-helper:1.3.0
@ -77,40 +83,40 @@ androidx.emoji2:emoji2:1.3.0
androidx.exifinterface:exifinterface:1.3.7 androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.4 androidx.fragment:fragment:1.5.4
androidx.graphics:graphics-path:1.0.1 androidx.graphics:graphics-path:1.0.1
androidx.hilt:hilt-common:1.1.0 androidx.hilt:hilt-common:1.2.0
androidx.hilt:hilt-navigation-compose:1.2.0 androidx.hilt:hilt-navigation-compose:1.2.0
androidx.hilt:hilt-navigation:1.2.0 androidx.hilt:hilt-navigation:1.2.0
androidx.hilt:hilt-work:1.1.0 androidx.hilt:hilt-work:1.2.0
androidx.interpolator:interpolator:1.0.0 androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.8.3 androidx.lifecycle:lifecycle-common-java8:2.8.6
androidx.lifecycle:lifecycle-common-jvm:2.8.3 androidx.lifecycle:lifecycle-common-jvm:2.8.6
androidx.lifecycle:lifecycle-common:2.8.3 androidx.lifecycle:lifecycle-common:2.8.6
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.3 androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.6
androidx.lifecycle:lifecycle-livedata-core:2.8.3 androidx.lifecycle:lifecycle-livedata-core:2.8.6
androidx.lifecycle:lifecycle-livedata:2.8.3 androidx.lifecycle:lifecycle-livedata:2.8.6
androidx.lifecycle:lifecycle-process:2.8.3 androidx.lifecycle:lifecycle-process:2.8.6
androidx.lifecycle:lifecycle-runtime-android:2.8.3 androidx.lifecycle:lifecycle-runtime-android:2.8.6
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.3 androidx.lifecycle:lifecycle-runtime-compose-android:2.8.6
androidx.lifecycle:lifecycle-runtime-compose:2.8.3 androidx.lifecycle:lifecycle-runtime-compose:2.8.6
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.3 androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.6
androidx.lifecycle:lifecycle-runtime-ktx:2.8.3 androidx.lifecycle:lifecycle-runtime-ktx:2.8.6
androidx.lifecycle:lifecycle-runtime:2.8.3 androidx.lifecycle:lifecycle-runtime:2.8.6
androidx.lifecycle:lifecycle-service:2.8.3 androidx.lifecycle:lifecycle-service:2.8.6
androidx.lifecycle:lifecycle-viewmodel-android:2.8.3 androidx.lifecycle:lifecycle-viewmodel-android:2.8.6
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.8.3 androidx.lifecycle:lifecycle-viewmodel-compose-android:2.8.6
androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3 androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3 androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.3 androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.6
androidx.lifecycle:lifecycle-viewmodel:2.8.3 androidx.lifecycle:lifecycle-viewmodel:2.8.6
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-beta01
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,57 +138,57 @@ 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
com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.accompanist:accompanist-permissions:0.34.0 com.google.accompanist:accompanist-permissions:0.34.0
com.google.android.datatransport:transport-api:3.1.0 com.google.android.datatransport:transport-api:3.2.0
com.google.android.datatransport:transport-backend-cct:3.1.9 com.google.android.datatransport:transport-backend-cct:3.3.0
com.google.android.datatransport:transport-runtime:3.1.9 com.google.android.datatransport:transport-runtime:3.3.0
com.google.android.gms:play-services-ads-identifier:18.0.0 com.google.android.gms:play-services-ads-identifier:18.0.0
com.google.android.gms:play-services-base:18.0.1 com.google.android.gms:play-services-base:18.5.0
com.google.android.gms:play-services-basement:18.4.0 com.google.android.gms:play-services-basement:18.4.0
com.google.android.gms:play-services-cloud-messaging:17.2.0 com.google.android.gms:play-services-cloud-messaging:17.2.0
com.google.android.gms:play-services-measurement-api:22.0.2 com.google.android.gms:play-services-measurement-api:22.1.0
com.google.android.gms:play-services-measurement-base:22.0.2 com.google.android.gms:play-services-measurement-base:22.1.0
com.google.android.gms:play-services-measurement-impl:22.0.2 com.google.android.gms:play-services-measurement-impl:22.1.0
com.google.android.gms:play-services-measurement-sdk-api:22.0.2 com.google.android.gms:play-services-measurement-sdk-api:22.1.0
com.google.android.gms:play-services-measurement-sdk:22.0.2 com.google.android.gms:play-services-measurement-sdk:22.1.0
com.google.android.gms:play-services-measurement:22.0.2 com.google.android.gms:play-services-measurement:22.1.0
com.google.android.gms:play-services-oss-licenses:17.0.1 com.google.android.gms:play-services-oss-licenses:17.1.0
com.google.android.gms:play-services-stats:17.0.2 com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.2.0 com.google.android.gms:play-services-tasks:18.2.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.52
com.google.dagger:dagger:2.51.1 com.google.dagger:dagger:2.52
com.google.dagger:hilt-android:2.51.1 com.google.dagger:hilt-android:2.52
com.google.dagger:hilt-core:2.51.1 com.google.dagger:hilt-core:2.52
com.google.errorprone:error_prone_annotations:2.26.0 com.google.errorprone:error_prone_annotations:2.26.0
com.google.firebase:firebase-abt:21.1.1 com.google.firebase:firebase-abt:21.1.1
com.google.firebase:firebase-analytics:22.0.2 com.google.firebase:firebase-analytics:22.1.0
com.google.firebase:firebase-annotations:16.2.0 com.google.firebase:firebase-annotations:16.2.0
com.google.firebase:firebase-bom:33.1.1 com.google.firebase:firebase-bom:33.3.0
com.google.firebase:firebase-common-ktx:21.0.0 com.google.firebase:firebase-common-ktx:21.0.0
com.google.firebase:firebase-common:21.0.0 com.google.firebase:firebase-common:21.0.0
com.google.firebase:firebase-components:18.0.0 com.google.firebase:firebase-components:18.0.0
com.google.firebase:firebase-config-interop:16.0.1 com.google.firebase:firebase-config-interop:16.0.1
com.google.firebase:firebase-config:22.0.0 com.google.firebase:firebase-config:22.0.0
com.google.firebase:firebase-crashlytics:19.0.2 com.google.firebase:firebase-crashlytics:19.1.0
com.google.firebase:firebase-datatransport:18.2.0 com.google.firebase:firebase-datatransport:19.0.0
com.google.firebase:firebase-encoders-json:18.0.1 com.google.firebase:firebase-encoders-json:18.0.1
com.google.firebase:firebase-encoders-proto:16.0.0 com.google.firebase:firebase-encoders-proto:16.0.0
com.google.firebase:firebase-encoders:17.0.0 com.google.firebase:firebase-encoders:17.0.0
com.google.firebase:firebase-iid-interop:17.1.0 com.google.firebase:firebase-iid-interop:17.1.0
com.google.firebase:firebase-installations-interop:17.1.1 com.google.firebase:firebase-installations-interop:17.2.0
com.google.firebase:firebase-installations:18.0.0 com.google.firebase:firebase-installations:18.0.0
com.google.firebase:firebase-measurement-connector:20.0.1 com.google.firebase:firebase-measurement-connector:20.0.1
com.google.firebase:firebase-messaging:24.0.0 com.google.firebase:firebase-messaging:24.0.1
com.google.firebase:firebase-perf:21.0.1 com.google.firebase:firebase-perf:21.0.1
com.google.firebase:firebase-sessions:2.0.2 com.google.firebase:firebase-sessions:2.0.4
com.google.firebase:protolite-well-known-types:18.0.0 com.google.firebase:protolite-well-known-types:18.0.0
com.google.guava:failureaccess:1.0.1 com.google.guava:failureaccess:1.0.1
com.google.guava:guava:31.1-android com.google.guava:guava:31.1-android
@ -190,31 +196,34 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:1.3 com.google.j2objc:j2objc-annotations:1.3
com.google.protobuf:protobuf-javalite:4.26.1 com.google.protobuf:protobuf-javalite:4.26.1
com.google.protobuf:protobuf-kotlin-lite:4.26.1 com.google.protobuf:protobuf-kotlin-lite:4.26.1
com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0 com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0
com.squareup.okhttp3:logging-interceptor:4.12.0 com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.8.0 com.squareup.okio:okio-jvm:3.9.0
com.squareup.okio:okio:3.8.0 com.squareup.okio:okio:3.9.0
com.squareup.retrofit2:retrofit:2.9.0 com.squareup.retrofit2:retrofit:2.11.0
io.coil-kt:coil-base:2.6.0 io.coil-kt:coil-base:2.7.0
io.coil-kt:coil-compose-base:2.6.0 io.coil-kt:coil-compose-base:2.7.0
io.coil-kt:coil-compose:2.6.0 io.coil-kt:coil-compose:2.7.0
io.coil-kt:coil-svg:2.6.0 io.coil-kt:coil-svg:2.7.0
io.coil-kt:coil:2.6.0 io.coil-kt:coil:2.7.0
jakarta.inject:jakarta.inject-api:2.0.1
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-android-extensions-runtime:1.9.22
org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22
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.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.5.0 org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1
org.jetbrains.kotlinx:kotlinx-datetime:0.5.0 org.jetbrains.kotlinx:kotlinx-datetime:0.6.1
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3
org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3

@ -1,9 +0,0 @@
# Fix for Retrofit issue https://github.com/square/retrofit/issues/3751
# Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items).
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type argument
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation

@ -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>

File diff suppressed because it is too large Load Diff

@ -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() {
@ -78,7 +75,7 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var userNewsResourceRepository: UserNewsResourceRepository lateinit var userNewsResourceRepository: UserNewsResourceRepository
val viewModel: MainActivityViewModel by viewModels() private val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
@ -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,41 +14,47 @@
* 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.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.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)
@ -58,6 +64,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"
@ -65,39 +76,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)
}
}
} }
} }
@ -107,15 +97,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)
}
}
} }
} }
@ -125,15 +112,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)
}
}
} }
} }
@ -147,15 +131,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)
}
}
} }
} }
@ -169,27 +150,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)
@ -197,21 +176,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()
@ -220,3 +197,8 @@ class InterestsListDetailScreenTest {
} }
} }
} }
private fun AndroidComposeTestRule<*, *>.stringResource(
@StringRes resId: Int,
): ReadOnlyProperty<Any, String> =
ReadOnlyProperty { _, _ -> activity.getString(resId) }

@ -59,6 +59,7 @@ android {
baselineProfile { baselineProfile {
// This specifies the managed devices to use that you run the tests on. // This specifies the managed devices to use that you run the tests on.
managedDevices.clear()
managedDevices += "pixel6Api33" managedDevices += "pixel6Api33"
// Don't use a connected device but rely on a GMD for consistency between local and CI builds. // Don't use a connected device but rely on a GMD for consistency between local and CI builds.

@ -0,0 +1,50 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid
import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.TraceSectionMetric
/**
* Custom Metrics to measure baseline profile effectiveness.
*/
class BaselineProfileMetrics {
companion object {
/**
* A [TraceSectionMetric] that tracks the time spent in JIT compilation.
*
* This number should go down when a baseline profile is applied properly.
*/
@OptIn(ExperimentalMetricApi::class)
val jitCompilationMetric = TraceSectionMetric("JIT Compiling %", label = "JIT compilation")
/**
* A [TraceSectionMetric] that tracks the time spent in class initialization.
*
* This number should go down when a baseline profile is applied properly.
*/
@OptIn(ExperimentalMetricApi::class)
val classInitMetric = TraceSectionMetric("L%/%;", label = "ClassInit")
/**
* Metrics relevant to startup and baseline profile effectiveness measurement.
*/
@OptIn(ExperimentalMetricApi::class)
val allMetrics = listOf(StartupTimingMetric(), jitCompilationMetric, classInitMetric)
}
}

@ -20,9 +20,9 @@ import androidx.benchmark.macro.BaselineProfileMode.Disable
import androidx.benchmark.macro.BaselineProfileMode.Require import androidx.benchmark.macro.BaselineProfileMode.Require
import androidx.benchmark.macro.CompilationMode import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.StartupMode.COLD import androidx.benchmark.macro.StartupMode.COLD
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import com.google.samples.apps.nowinandroid.BaselineProfileMetrics
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications import com.google.samples.apps.nowinandroid.allowNotifications
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
@ -58,7 +58,7 @@ class StartupBenchmark {
private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated( private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME, packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()), metrics = BaselineProfileMetrics.allMetrics,
compilationMode = compilationMode, compilationMode = compilationMode,
// More iterations result in higher statistical significance. // More iterations result in higher statistical significance.
iterations = 20, iterations = 20,

@ -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() else it.toString()
}
fun Project.configureBadgingTasks( fun Project.configureBadgingTasks(
baseExtension: BaseExtension, baseExtension: BaseExtension,
componentsExtension: ApplicationAndroidComponentsExtension, componentsExtension: ApplicationAndroidComponentsExtension,

@ -30,7 +30,13 @@ buildscript {
} }
// Lists all plugins used throughout the project /*
* By listing all the plugins used throughout all subprojects in the root project build script, it
* ensures that the build script classpath remains the same for all projects. This avoids potential
* problems with mismatching versions of transitive plugin dependencies. A subproject that applies
* an unlisted plugin will have that plugin and its dependencies _appended_ to the classpath, not
* replacing pre-existing dependencies.
*/
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false

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,

@ -22,12 +22,12 @@ import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Call import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query
import javax.inject.Inject import javax.inject.Inject

@ -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)
} }
} }

@ -64,20 +64,18 @@ class BookmarksViewModelTest {
@Test @Test
fun oneBookmark_showsInFeed() = runTest { fun oneBookmark_showsInFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }
newsRepository.sendNewsResources(newsResourcesTestData) newsRepository.sendNewsResources(newsResourcesTestData)
userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true) userDataRepository.setNewsResourceBookmarked(newsResourcesTestData[0].id, true)
val item = viewModel.feedUiState.value val item = viewModel.feedUiState.value
assertIs<Success>(item) assertIs<Success>(item)
assertEquals(item.feed.size, 1) assertEquals(item.feed.size, 1)
collectJob.cancel()
} }
@Test @Test
fun oneBookmark_whenRemoving_removesFromFeed() = runTest { fun oneBookmark_whenRemoving_removesFromFeed() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedUiState.collect() }
// Set the news resources to be used by this test // Set the news resources to be used by this test
newsRepository.sendNewsResources(newsResourcesTestData) newsRepository.sendNewsResources(newsResourcesTestData)
// Start with the resource saved // Start with the resource saved
@ -88,7 +86,5 @@ class BookmarksViewModelTest {
val item = viewModel.feedUiState.value val item = viewModel.feedUiState.value
assertIs<Success>(item) assertIs<Success>(item)
assertEquals(item.feed.size, 0) assertEquals(item.feed.size, 0)
collectJob.cancel()
} }
} }

@ -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)

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Box
import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsNotEnabled
@ -52,7 +52,7 @@ class ForYouScreenTest {
@Test @Test
fun circularProgressIndicator_whenScreenIsLoading_exists() { fun circularProgressIndicator_whenScreenIsLoading_exists() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { Box {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.Loading, onboardingUiState = OnboardingUiState.Loading,
@ -78,7 +78,7 @@ class ForYouScreenTest {
@Test @Test
fun circularProgressIndicator_whenScreenIsSyncing_exists() { fun circularProgressIndicator_whenScreenIsSyncing_exists() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { Box {
ForYouScreen( ForYouScreen(
isSyncing = true, isSyncing = true,
onboardingUiState = OnboardingUiState.NotShown, onboardingUiState = OnboardingUiState.NotShown,
@ -106,7 +106,7 @@ class ForYouScreenTest {
val testData = followableTopicTestData.map { it.copy(isFollowed = false) } val testData = followableTopicTestData.map { it.copy(isFollowed = false) }
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { Box {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.Shown( onboardingUiState = OnboardingUiState.Shown(
@ -149,7 +149,7 @@ class ForYouScreenTest {
@Test @Test
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() { fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { Box {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = onboardingUiState =
@ -196,7 +196,7 @@ class ForYouScreenTest {
@Test @Test
fun feed_whenInterestsSelectedAndLoading_showsLoadingIndicator() { fun feed_whenInterestsSelectedAndLoading_showsLoadingIndicator() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { Box {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = onboardingUiState =
@ -223,7 +223,7 @@ class ForYouScreenTest {
@Test @Test
fun feed_whenNoInterestsSelectionAndLoading_showsLoadingIndicator() { fun feed_whenNoInterestsSelectionAndLoading_showsLoadingIndicator() {
composeTestRule.setContent { composeTestRule.setContent {
BoxWithConstraints { Box {
ForYouScreen( ForYouScreen(
isSyncing = false, isSyncing = false,
onboardingUiState = OnboardingUiState.NotShown, onboardingUiState = OnboardingUiState.NotShown,

@ -82,7 +82,6 @@ import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tracing.trace
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus.Denied import com.google.accompanist.permissions.PermissionStatus.Denied
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
@ -106,7 +105,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(),
@ -320,7 +319,7 @@ private fun TopicSelection(
onboardingUiState: OnboardingUiState.Shown, onboardingUiState: OnboardingUiState.Shown,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) = trace("TopicSelection") { ) {
val lazyGridState = rememberLazyGridState() val lazyGridState = rememberLazyGridState()
val topicSelectionTestTag = "forYou:topicSelection" val topicSelectionTestTag = "forYou:topicSelection"
@ -381,7 +380,7 @@ private fun SingleTopicButton(
imageUrl: String, imageUrl: String,
isSelected: Boolean, isSelected: Boolean,
onClick: (String, Boolean) -> Unit, onClick: (String, Boolean) -> Unit,
) = trace("SingleTopicButton") { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.width(312.dp) .width(312.dp)

@ -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
@ -98,9 +98,8 @@ class ForYouViewModelTest {
@Test @Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest { fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
val collectJob1 = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -109,31 +108,24 @@ class ForYouViewModelTest {
viewModel.onboardingUiState.value, viewModel.onboardingUiState.value,
) )
assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value) assertEquals(NewsFeedUiState.Loading, viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
} }
@Test @Test
fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest { fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest {
syncManager.setSyncing(true) syncManager.setSyncing(true)
val collectJob = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() }
assertEquals( assertEquals(
true, true,
viewModel.isSyncing.value, viewModel.isSyncing.value,
) )
collectJob.cancel()
} }
@Test @Test
fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest { fun onboardingStateIsLoadingWhenTopicsAreLoading() = runTest {
val collectJob1 = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
@ -142,16 +134,12 @@ class ForYouViewModelTest {
viewModel.onboardingUiState.value, viewModel.onboardingUiState.value,
) )
assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value) assertEquals(NewsFeedUiState.Success(emptyList()), viewModel.feedState.value)
collectJob1.cancel()
collectJob2.cancel()
} }
@Test @Test
fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest { fun onboardingIsShownWhenNewsResourcesAreLoading() = runTest {
val collectJob1 = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
@ -202,16 +190,12 @@ class ForYouViewModelTest {
), ),
viewModel.feedState.value, viewModel.feedState.value,
) )
collectJob1.cancel()
collectJob2.cancel()
} }
@Test @Test
fun onboardingIsShownAfterLoadingEmptyFollowedTopics() = runTest { fun onboardingIsShownAfterLoadingEmptyFollowedTopics() = runTest {
val collectJob1 = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
@ -263,16 +247,12 @@ class ForYouViewModelTest {
), ),
viewModel.feedState.value, viewModel.feedState.value,
) )
collectJob1.cancel()
collectJob2.cancel()
} }
@Test @Test
fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest { fun onboardingIsNotShownAfterUserDismissesOnboarding() = runTest {
val collectJob1 = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
@ -299,16 +279,12 @@ class ForYouViewModelTest {
), ),
viewModel.feedState.value, viewModel.feedState.value,
) )
collectJob1.cancel()
collectJob2.cancel()
} }
@Test @Test
fun topicSelectionUpdatesAfterSelectingTopic() = runTest { fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
val collectJob1 = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
@ -352,16 +328,12 @@ class ForYouViewModelTest {
), ),
viewModel.feedState.value, viewModel.feedState.value,
) )
collectJob1.cancel()
collectJob2.cancel()
} }
@Test @Test
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest { fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
val collectJob1 = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
topicsRepository.sendTopics(sampleTopics) topicsRepository.sendTopics(sampleTopics)
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
@ -416,16 +388,12 @@ class ForYouViewModelTest {
), ),
viewModel.feedState.value, viewModel.feedState.value,
) )
collectJob1.cancel()
collectJob2.cancel()
} }
@Test @Test
fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest { fun newsResourceSelectionUpdatesAfterLoadingFollowedTopics() = runTest {
val collectJob1 = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.onboardingUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val collectJob2 = launch(UnconfinedTestDispatcher()) { viewModel.feedState.collect() }
val followedTopicIds = setOf("1") val followedTopicIds = setOf("1")
val userData = emptyUserData.copy( val userData = emptyUserData.copy(
@ -460,19 +428,15 @@ class ForYouViewModelTest {
), ),
viewModel.feedState.value, viewModel.feedState.value,
) )
collectJob1.cancel()
collectJob2.cancel()
} }
@Test @Test
fun deepLinkedNewsResourceIsFetchedAndResetAfterViewing() = runTest { fun deepLinkedNewsResourceIsFetchedAndResetAfterViewing() = runTest {
val collectJob = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.deepLinkedNewsResource.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.deepLinkedNewsResource.collect() }
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,15 +460,13 @@ 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,
), ),
), ),
), ),
), ),
) )
collectJob.cancel()
} }
@Test @Test

@ -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,
) )
@ -68,17 +80,15 @@ class InterestsViewModelTest {
@Test @Test
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest { fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
assertEquals(InterestsUiState.Loading, viewModel.uiState.value) assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
} }
@Test @Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest { fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id val toggleTopicId = testOutputTopics[1].topic.id
topicsRepository.sendTopics(testInputTopics.map { it.topic }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
@ -102,13 +112,11 @@ class InterestsViewModelTest {
), ),
viewModel.uiState.value, viewModel.uiState.value,
) )
collectJob.cancel()
} }
@Test @Test
fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest { fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id val toggleTopicId = testOutputTopics[1].topic.id
@ -135,8 +143,6 @@ class InterestsViewModelTest {
), ),
viewModel.uiState.value, viewModel.uiState.value,
) )
collectJob.cancel()
} }
} }

@ -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() }
}
} }
} }

@ -85,20 +85,16 @@ class SearchViewModelTest {
fun stateIsEmptyQuery_withEmptySearchQuery() = runTest { fun stateIsEmptyQuery_withEmptySearchQuery() = runTest {
searchContentsRepository.addNewsResources(newsResourcesTestData) searchContentsRepository.addNewsResources(newsResourcesTestData)
searchContentsRepository.addTopics(topicsTestData) searchContentsRepository.addTopics(topicsTestData)
val collectJob = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
viewModel.onSearchQueryChanged("") viewModel.onSearchQueryChanged("")
assertEquals(EmptyQuery, viewModel.searchResultUiState.value) assertEquals(EmptyQuery, viewModel.searchResultUiState.value)
collectJob.cancel()
} }
@Test @Test
fun emptyResultIsReturned_withNotMatchingQuery() = runTest { fun emptyResultIsReturned_withNotMatchingQuery() = runTest {
val collectJob = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
viewModel.onSearchQueryChanged("XXX") viewModel.onSearchQueryChanged("XXX")
searchContentsRepository.addNewsResources(newsResourcesTestData) searchContentsRepository.addNewsResources(newsResourcesTestData)
@ -106,32 +102,24 @@ class SearchViewModelTest {
val result = viewModel.searchResultUiState.value val result = viewModel.searchResultUiState.value
assertIs<SearchResultUiState.Success>(result) assertIs<SearchResultUiState.Success>(result)
collectJob.cancel()
} }
@Test @Test
fun recentSearches_verifyUiStateIsSuccess() = runTest { fun recentSearches_verifyUiStateIsSuccess() = runTest {
val collectJob = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.recentSearchQueriesUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.recentSearchQueriesUiState.collect() }
viewModel.onSearchTriggered("kotlin") viewModel.onSearchTriggered("kotlin")
val result = viewModel.recentSearchQueriesUiState.value val result = viewModel.recentSearchQueriesUiState.value
assertIs<Success>(result) assertIs<Success>(result)
collectJob.cancel()
} }
@Test @Test
fun searchNotReady_withNoFtsTableEntity() = runTest { fun searchNotReady_withNoFtsTableEntity() = runTest {
val collectJob = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() }
viewModel.onSearchQueryChanged("") viewModel.onSearchQueryChanged("")
assertEquals(SearchNotReady, viewModel.searchResultUiState.value) assertEquals(SearchNotReady, viewModel.searchResultUiState.value)
collectJob.cancel()
} }
@Test @Test

@ -52,8 +52,7 @@ class SettingsViewModelTest {
@Test @Test
fun stateIsSuccessAfterUserDataLoaded() = runTest { fun stateIsSuccessAfterUserDataLoaded() = runTest {
val collectJob = backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.settingsUiState.collect() }
launch(UnconfinedTestDispatcher()) { viewModel.settingsUiState.collect() }
userDataRepository.setThemeBrand(ANDROID) userDataRepository.setThemeBrand(ANDROID)
userDataRepository.setDarkThemeConfig(DARK) userDataRepository.setDarkThemeConfig(DARK)
@ -68,7 +67,5 @@ class SettingsViewModelTest {
), ),
viewModel.settingsUiState.value, viewModel.settingsUiState.value,
) )
collectJob.cancel()
} }
} }

@ -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,
@ -73,7 +85,7 @@ class TopicViewModelTest {
@Test @Test
fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest { fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic)) topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
@ -85,8 +97,6 @@ class TopicViewModelTest {
).first() ).first()
assertEquals(topicFromRepository, item.followableTopic.topic) assertEquals(topicFromRepository, item.followableTopic.topic)
collectJob.cancel()
} }
@Test @Test
@ -101,18 +111,16 @@ class TopicViewModelTest {
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest { fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
assertEquals(TopicUiState.Loading, viewModel.topicUiState.value) assertEquals(TopicUiState.Loading, viewModel.topicUiState.value)
collectJob.cancel()
} }
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() = fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() =
runTest { runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
@ -121,14 +129,12 @@ class TopicViewModelTest {
assertIs<TopicUiState.Success>(topicUiState) assertIs<TopicUiState.Success>(topicUiState)
assertIs<NewsUiState.Loading>(newsUiState) assertIs<NewsUiState.Loading>(newsUiState)
collectJob.cancel()
} }
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() = fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest { runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { backgroundScope.launch(UnconfinedTestDispatcher()) {
combine( combine(
viewModel.topicUiState, viewModel.topicUiState,
viewModel.newsUiState, viewModel.newsUiState,
@ -143,13 +149,11 @@ class TopicViewModelTest {
assertIs<TopicUiState.Success>(topicUiState) assertIs<TopicUiState.Success>(topicUiState)
assertIs<NewsUiState.Success>(newsUiState) assertIs<NewsUiState.Success>(newsUiState)
collectJob.cancel()
} }
@Test @Test
fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest { fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } backgroundScope.launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
// Set which topic IDs are followed, not including 0. // Set which topic IDs are followed, not including 0.
@ -161,8 +165,6 @@ class TopicViewModelTest {
TopicUiState.Success(followableTopic = testOutputTopics[0]), TopicUiState.Success(followableTopic = testOutputTopics[0]),
viewModel.topicUiState.value, viewModel.topicUiState.value,
) )
collectJob.cancel()
} }
} }

@ -8,7 +8,23 @@
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
# Ensure important default jvmargs aren't overwritten. See https://github.com/gradle/gradle/issues/19750 # Ensure important default jvmargs aren't overwritten. See https://github.com/gradle/gradle/issues/19750
org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g #
# For more information about how Gradle memory options were chosen:
# - Metaspace See https://www.jasonpearson.dev/metaspace-in-jvm-builds/
# - SoftRefLRUPolicyMSPerMB would default to 1000 which with a 4gb heap translates to ~51 minutes.
# A value of 1 means ~4 seconds before SoftRefs can be collected, which means its realistic to
# collect them as needed during a build that should take seconds to minutes.
# - CodeCache normally defaults to a very small size. Increasing it from platform defaults of 32-48m
# because of how many classes can be loaded into memory and then cached as native compiled code
# for a small speed boost.
org.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=1 -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Xmx4g -Xms4g
# For more information about how Kotlin Daemon memory options were chosen:
# - Kotlin JVM args only inherit Xmx, ReservedCodeCache, and MaxMetaspace. Since we are specifying
# other args we need to specify all of them here.
# - We're using the Kotlin Gradle Plugin's default value for ReservedCodeCacheSize, if we do not then
# the Gradle JVM arg value for ReservedCodeCacheSize will be used.
kotlin.daemon.jvmargs=-Dfile.encoding=UTF-8 -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=1 -XX:ReservedCodeCacheSize=320m -XX:+HeapDumpOnOutOfMemoryError -Xmx4g -Xms4g
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit # This option should only be used with decoupled projects. More details, visit
@ -22,7 +38,7 @@ org.gradle.configureondemand=false
org.gradle.caching=true org.gradle.caching=true
# Enable configuration caching between builds. # Enable configuration caching between builds.
org.gradle.configuration-cache=true org.gradle.configuration-cache=false
# This option is set because of https://github.com/google/play-services-plugins/issues/246 # This option is set because of https://github.com/google/play-services-plugins/issues/246
# to generate the Configuration Cache regardless of incompatible tasks. # to generate the Configuration Cache regardless of incompatible tasks.
# See https://github.com/android/nowinandroid/issues/1022 before using it. # See https://github.com/android/nowinandroid/issues/1022 before using it.

@ -2,56 +2,53 @@
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.1"
androidTools = "31.4.1" androidTools = "31.6.1"
androidxActivity = "1.8.2" androidxActivity = "1.9.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.13.1"
androidxCoreSplashscreen = "1.0.1" androidxCoreSplashscreen = "1.0.1"
androidxDataStore = "1.0.0" androidxDataStore = "1.1.1"
androidxEspresso = "3.5.1" androidxEspresso = "3.5.1"
androidxHiltNavigationCompose = "1.2.0" androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.8.3" androidxLifecycle = "2.8.6"
androidxMacroBenchmark = "1.2.4" androidxMacroBenchmark = "1.3.0"
androidxMetrics = "1.0.0-alpha04" androidxMetrics = "1.0.0-beta01"
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.2.1"
androidxTestRules = "1.5.0" androidxTestRules = "1.6.1"
androidxTestRunner = "1.5.2" androidxTestRunner = "1.6.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.7.0"
dependencyGuard = "0.5.0" dependencyGuard = "0.5.0"
firebaseBom = "33.1.1" firebaseBom = "33.3.0"
firebaseCrashlyticsPlugin = "2.9.9" firebaseCrashlyticsPlugin = "2.9.9"
firebasePerfPlugin = "1.4.2" firebasePerfPlugin = "1.4.2"
gmsPlugin = "4.4.1" gmsPlugin = "4.4.1"
googleOss = "17.0.1" googleOss = "17.1.0"
googleOssPlugin = "0.10.6" googleOssPlugin = "0.10.6"
hilt = "2.51.1" hilt = "2.52"
hiltExt = "1.1.0" hiltExt = "1.2.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.9.0"
kotlinxDatetime = "0.5.0" kotlinxDatetime = "0.6.1"
kotlinxSerializationJson = "1.6.3" kotlinxSerializationJson = "1.6.3"
ksp = "2.0.0-1.0.21" ksp = "2.0.20-1.0.25"
moduleGraph = "2.5.0" moduleGraph = "2.7.1"
okhttp = "4.12.0" okhttp = "4.12.0"
protobuf = "4.26.1" protobuf = "4.26.1"
protobufPlugin = "0.9.4" protobufPlugin = "0.9.4"
retrofit = "2.9.0" retrofit = "2.11.0"
retrofitKotlinxSerializationJson = "1.0.0" retrofitKotlinxSerializationJson = "1.0.0"
robolectric = "4.12.2" robolectric = "4.12.2"
roborazzi = "1.7.0" roborazzi = "1.7.0"
@ -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" }
@ -141,7 +139,7 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor",
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" } retrofit-kotlin-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
@ -181,17 +179,17 @@ room = { id = "androidx.room", version.ref = "room" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }
# Plugins defined by this project # Plugins defined by this project
nowinandroid-android-application = { id = "nowinandroid.android.application", version = "unspecified" } nowinandroid-android-application = { id = "nowinandroid.android.application" }
nowinandroid-android-application-compose = { id = "nowinandroid.android.application.compose", version = "unspecified" } nowinandroid-android-application-compose = { id = "nowinandroid.android.application.compose" }
nowinandroid-android-application-firebase = { id = "nowinandroid.android.application.firebase", version = "unspecified" } nowinandroid-android-application-firebase = { id = "nowinandroid.android.application.firebase" }
nowinandroid-android-application-flavors = { id = "nowinandroid.android.application.flavors", version = "unspecified" } nowinandroid-android-application-flavors = { id = "nowinandroid.android.application.flavors" }
nowinandroid-android-application-jacoco = { id = "nowinandroid.android.application.jacoco", version = "unspecified" } nowinandroid-android-application-jacoco = { id = "nowinandroid.android.application.jacoco" }
nowinandroid-android-feature = { id = "nowinandroid.android.feature", version = "unspecified" } nowinandroid-android-feature = { id = "nowinandroid.android.feature" }
nowinandroid-android-library = { id = "nowinandroid.android.library", version = "unspecified" } nowinandroid-android-library = { id = "nowinandroid.android.library" }
nowinandroid-android-library-compose = { id = "nowinandroid.android.library.compose", version = "unspecified" } nowinandroid-android-library-compose = { id = "nowinandroid.android.library.compose" }
nowinandroid-android-library-jacoco = { id = "nowinandroid.android.library.jacoco", version = "unspecified" } nowinandroid-android-library-jacoco = { id = "nowinandroid.android.library.jacoco" }
nowinandroid-android-lint = { id = "nowinandroid.android.lint", version = "unspecified" } nowinandroid-android-lint = { id = "nowinandroid.android.lint" }
nowinandroid-android-room = { id = "nowinandroid.android.room", version = "unspecified" } nowinandroid-android-room = { id = "nowinandroid.android.room" }
nowinandroid-android-test = { id = "nowinandroid.android.test", version = "unspecified" } nowinandroid-android-test = { id = "nowinandroid.android.test" }
nowinandroid-hilt = { id = "nowinandroid.hilt", version = "unspecified" } nowinandroid-hilt = { id = "nowinandroid.hilt" }
nowinandroid-jvm-library = { id = "nowinandroid.jvm.library", version = "unspecified" } nowinandroid-jvm-library = { id = "nowinandroid.jvm.library" }

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