Merge branch 'main' into firebase-ktx

pull/1062/head
Simon Marquis 3 months ago committed by GitHub
commit 2a43c3f725
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -12,12 +12,11 @@ updates:
registries: "*"
labels: [ "version update" ]
groups:
kotlin-ksp-compose:
kotlin-ksp:
patterns:
- "org.jetbrains.kotlin:*"
- "org.jetbrains.kotlin.jvm"
- "com.google.devtools.ksp"
- "androidx.compose.compiler:compiler"
open-pull-requests-limit: 10
registries:
maven-google:

@ -1,17 +1,25 @@
Thanks for submitting a pull request. Please include the following information.
**DO NOT CREATE A PULL REQUEST WITHOUT READING THESE INSTRUCTIONS**
**What I have done and why**
Include a summary of what your pull request contains, and why you have made these changes.
## Instructions
Thanks for submitting a pull request. To accept your pull request we need you do a few things:
**If this is your first pull request**
- [Sign the contributors license agreement](https://cla.developers.google.com/)
**Ensure tests pass and code is formatted correctly**
Fixes #<issue_number_goes_here>
- Run local tests on the `DemoDebug` variant by running `./gradlew testDemoDebug`
- Fix code formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply`
**Do tests pass?**
- [ ] Run local tests on `DemoDebug` variant: `./gradlew testDemoDebug`
- [ ] Check formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply`
**Add a description**
**Is this your first pull request?**
- [ ] [Sign the CLA](https://cla.developers.google.com/)
- [ ] Run `./tools/setup.sh`
- [ ] Import the code formatting style as explained in [the setup script](/tools/setup.sh#L40).
We need to know what you've done and why you've done it. Include a summary of what your pull request contains, and why you have made these changes. Include links to any relevant issues which it fixes.
[Here's an example](https://github.com/android/nowinandroid/pull/1257).
**NOW DELETE THIS LINE AND EVERYTHING ABOVE IT**
**What I have done and why**
\<add your PR description here\>

@ -17,6 +17,7 @@ jobs:
permissions:
contents: write
pull-requests: write
timeout-minutes: 60
@ -25,7 +26,7 @@ jobs:
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
uses: gradle/wrapper-validation-action@v3
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
@ -37,7 +38,7 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
uses: gradle/gradle-build-action@v3
- name: Check build-logic
run: ./gradlew check -p build-logic
@ -45,13 +46,40 @@ jobs:
- name: Check spotless
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
- name: Check Dependency Guard
id: dependencyguard_verify
continue-on-error: true
run: ./gradlew dependencyGuard
- name: Prevent updating Dependency Guard baselines if this is a fork
id: checkfork_dependencyguard
continue-on-error: false
if: steps.dependencyguard_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::Dependency Guard failed, please update baselines with: ./gradlew dependencyGuardBaseline" && exit 1
# Runs if previous job failed
- name: Generate new Dependency Guard baselines if verification failed and it's a PR
id: dependencyguard_baseline
if: steps.dependencyguard_verify.outcome == 'failure' && github.event_name == 'pull_request'
run: |
./gradlew dependencyGuardBaseline
- name: Push new Dependency Guard baselines if available
uses: stefanzweifel/git-auto-commit-action@v5
if: steps.dependencyguard_baseline.outcome == 'success'
with:
file_pattern: '**/dependencies/*.txt'
disable_globbing: true
commit_message: "🤖 Updates baselines for Dependency Guard"
- name: Run all local screenshot tests (Roborazzi)
id: screenshotsverify
continue-on-error: true
run: ./gradlew verifyRoborazziDemoDebug
- name: Prevent pushing new screenshots if this is a fork
id: checkfork
id: checkfork_screenshots
continue-on-error: false
if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: |
@ -73,22 +101,19 @@ jobs:
commit_message: "🤖 Updates screenshots"
# Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots.
- name: Run local tests
- name: Run local tests and create report
if: always()
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
# release build
- name: Build all build type and flavor permutations
run: ./gradlew :app:assemble :benchmarks:assemble
-x pixel6Api33ProdNonMinifiedReleaseAndroidTest
-x pixel6Api33ProdNonMinifiedBenchmarkAndroidTest
-x pixel6Api33DemoNonMinifiedReleaseAndroidTest
-x pixel6Api33DemoNonMinifiedBenchmarkAndroidTest
-x collectDemoNonMinifiedReleaseBaselineProfile
-x collectDemoNonMinifiedBenchmarkBaselineProfile
-x collectProdNonMinifiedReleaseBaselineProfile
-x collectProdNonMinifiedBenchmarkBaselineProfile
- name: Upload build outputs (APKs)
uses: actions/upload-artifact@v4
@ -96,13 +121,20 @@ jobs:
name: APKs
path: '**/build/outputs/apk/**/*.apk'
- name: Upload test results (XML)
- name: Upload JVM local results (XML)
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
name: local-test-results
path: '**/build/test-results/test*UnitTest/**.xml'
- name: Upload screenshot results (PNG)
if: always()
uses: actions/upload-artifact@v4
with:
name: screenshot-test-results
path: '**/build/outputs/roborazzi/*_compare.png'
- name: Check lint
run: ./gradlew :app:lintProdRelease :app-nia-catalog:lintRelease :lint:lint
@ -117,13 +149,31 @@ jobs:
run: ./gradlew :app:checkProdReleaseBadging
androidTest:
runs-on: macOS-latest # enables hardware acceleration in the virtual machine
runs-on: ubuntu-latest
timeout-minutes: 55
strategy:
matrix:
api-level: [26, 30]
steps:
- name: Delete unnecessary tools 🔧
uses: jlumbroso/free-disk-space@v1.3.1
with:
android: false # Don't remove Android tools
tool-cache: true # Remove image tool cache - rm -rf "$AGENT_TOOLSDIRECTORY"
dotnet: true # rm -rf /usr/share/dotnet
haskell: true # rm -rf /opt/ghc...
swap-storage: true # rm -f /mnt/swapfile (4GiB)
docker-images: false # Takes 16s, enable if needed in the future
large-packages: false # includes google-cloud-sdk and it's slow
- 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: Checkout
uses: actions/checkout@v4
@ -137,12 +187,9 @@ jobs:
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
uses: gradle/gradle-build-action@v3
- name: Build projects before running emulator
run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest
- name: Run instrumentation tests
- name: Build projects and run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ matrix.api-level }}
@ -152,9 +199,41 @@ jobs:
heap-size: 600M
script: ./gradlew connectedDemoDebugAndroidTest --daemon
- name: Run local tests (including Roborazzi) for the combined coverage report (only API 30)
if: matrix.api-level == 30
# There is no need to verify Roborazzi tests to generate coverage.
run: ./gradlew testDemoDebugUnitTest -Proborazzi.test.verify=false # Add Prod if we ever add JVM tests for prod
# Add `createProdDebugUnitTestCoverageReport` if we ever add JVM tests for prod
- name: Generate coverage reports for Debug variants (only API 30)
if: matrix.api-level == 30
run: ./gradlew createDemoDebugCombinedCoverageReport
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: test-reports-${{ matrix.api-level }}
path: '**/build/reports/androidTests'
- name: Display local test coverage (only API 30)
if: matrix.api-level == 30
id: jacoco
uses: madrapps/jacoco-report@v1.6.1
with:
title: Combined test coverage report
min-coverage-overall: 40
min-coverage-changed-files: 60
paths: |
${{ github.workspace }}/**/build/reports/jacoco/**/*Report.xml
token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload local coverage reports (XML + HTML) (only API 30)
if: matrix.api-level == 30
uses: actions/upload-artifact@v4
with:
name: coverage-reports
if-no-files-found: error
compression-level: 1
overwrite: false
path: '**/build/reports/jacoco/'

@ -7,15 +7,22 @@ on:
jobs:
build:
runs-on: macos-latest
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- 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: Checkout
uses: actions/checkout@v4
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
uses: gradle/wrapper-validation-action@v3
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties

3
.gitignore vendored

@ -43,3 +43,6 @@ _sandbox
# Android Studio captures folder
captures/
# Kotlin
.kotlin

@ -122,9 +122,9 @@ tests against _all_ build variants which is both unecessary and will result in f
A screenshot test takes a screenshot of a screen or a UI component within the app, and compares it
with a previously recorded screenshot which is known to be rendered correctly.
For example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemoDebug/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt)
For example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt)
to verify that the navigation is displayed correctly on different screen sizes
([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemoDebug/screenshots)).
([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemo/screenshots)).
Now In Android uses [Roborazzi](https://github.com/takahirom/roborazzi) to run screenshot tests
of certain screens and UI components. When working with screenshot tests the following gradle tasks are useful:

@ -1,3 +1,3 @@
# :app-nia-catalog module
![Dependency graph](../docs/images/graphs/dep_graph_app_nia_catalog.png)
## Dependency graph
![Dependency graph](../docs/images/graphs/dep_graph_app_nia_catalog.svg)

@ -59,7 +59,7 @@ android {
// 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.
// TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.named("debug").get()
}
}
}

@ -1,99 +1,118 @@
androidx.activity:activity-compose:1.8.0
androidx.activity:activity-ktx:1.8.0
androidx.activity:activity:1.8.0
androidx.annotation:annotation-experimental:1.3.1
androidx.annotation:annotation-jvm:1.7.0
androidx.annotation:annotation:1.7.0
androidx.activity:activity-compose:1.8.2
androidx.activity:activity-ktx:1.8.2
androidx.activity:activity:1.8.2
androidx.annotation:annotation-experimental:1.4.0
androidx.annotation:annotation-jvm:1.8.0
androidx.annotation:annotation:1.8.0
androidx.appcompat:appcompat-resources:1.6.1
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.6.0
androidx.collection:collection-jvm:1.3.0
androidx.collection:collection:1.3.0
androidx.compose.animation:animation-android:1.5.4
androidx.compose.animation:animation-core-android:1.5.4
androidx.compose.animation:animation-core:1.5.4
androidx.compose.animation:animation:1.5.4
androidx.compose.foundation:foundation-android:1.5.4
androidx.compose.foundation:foundation-layout-android:1.5.4
androidx.compose.foundation:foundation-layout:1.5.4
androidx.compose.foundation:foundation:1.5.4
androidx.compose.material3:material3:1.1.2
androidx.compose.material:material-icons-core-android:1.5.4
androidx.compose.material:material-icons-core:1.5.4
androidx.compose.material:material-icons-extended-android:1.5.4
androidx.compose.material:material-icons-extended:1.5.4
androidx.compose.material:material-ripple-android:1.5.4
androidx.compose.material:material-ripple:1.5.4
androidx.compose.runtime:runtime-android:1.5.4
androidx.compose.runtime:runtime-saveable-android:1.5.4
androidx.compose.runtime:runtime-saveable:1.5.4
androidx.compose.runtime:runtime:1.5.4
androidx.compose.ui:ui-android:1.5.4
androidx.compose.ui:ui-geometry-android:1.5.4
androidx.compose.ui:ui-geometry:1.5.4
androidx.compose.ui:ui-graphics-android:1.5.4
androidx.compose.ui:ui-graphics:1.5.4
androidx.compose.ui:ui-text-android:1.5.4
androidx.compose.ui:ui-text:1.5.4
androidx.compose.ui:ui-tooling-preview-android:1.5.4
androidx.compose.ui:ui-tooling-preview:1.5.4
androidx.compose.ui:ui-unit-android:1.5.4
androidx.compose.ui:ui-unit:1.5.4
androidx.compose.ui:ui-util-android:1.5.4
androidx.compose.ui:ui-util:1.5.4
androidx.compose.ui:ui:1.5.4
androidx.compose:compose-bom:2023.10.01
androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0
androidx.collection:collection-ktx:1.4.0
androidx.collection:collection:1.4.0
androidx.compose.animation:animation-android:1.7.0-beta01
androidx.compose.animation:animation-core-android:1.7.0-beta01
androidx.compose.animation:animation-core:1.7.0-beta01
androidx.compose.animation:animation:1.7.0-beta01
androidx.compose.foundation:foundation-android:1.7.0-beta01
androidx.compose.foundation:foundation-layout-android:1.7.0-beta01
androidx.compose.foundation:foundation-layout:1.7.0-beta01
androidx.compose.foundation:foundation:1.7.0-beta01
androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive:1.0.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01
androidx.compose.material3:material3-android:1.3.0-beta01
androidx.compose.material3:material3:1.3.0-beta01
androidx.compose.material:material-icons-core-android:1.6.3
androidx.compose.material:material-icons-core:1.6.3
androidx.compose.material:material-icons-extended-android:1.6.3
androidx.compose.material:material-icons-extended:1.6.3
androidx.compose.material:material-ripple-android:1.7.0-beta01
androidx.compose.material:material-ripple:1.7.0-beta01
androidx.compose.runtime:runtime-android:1.7.0-beta01
androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01
androidx.compose.runtime:runtime-saveable:1.7.0-beta01
androidx.compose.runtime:runtime:1.7.0-beta01
androidx.compose.ui:ui-android:1.7.0-beta01
androidx.compose.ui:ui-geometry-android:1.7.0-beta01
androidx.compose.ui:ui-geometry:1.7.0-beta01
androidx.compose.ui:ui-graphics-android:1.7.0-beta01
androidx.compose.ui:ui-graphics:1.7.0-beta01
androidx.compose.ui:ui-text-android:1.7.0-beta01
androidx.compose.ui:ui-text:1.7.0-beta01
androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01
androidx.compose.ui:ui-tooling-preview:1.7.0-beta01
androidx.compose.ui:ui-unit-android:1.7.0-beta01
androidx.compose.ui:ui-unit:1.7.0-beta01
androidx.compose.ui:ui-util-android:1.7.0-beta01
androidx.compose.ui:ui-util:1.7.0-beta01
androidx.compose.ui:ui:1.7.0-beta01
androidx.compose:compose-bom:2024.02.02
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0
androidx.core:core:1.12.0
androidx.core:core-ktx:1.13.1
androidx.core:core:1.13.1
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0
androidx.emoji2:emoji2:1.4.0
androidx.exifinterface:exifinterface:1.3.6
androidx.emoji2:emoji2:1.3.0
androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.1
androidx.graphics:graphics-path:1.0.1
androidx.interpolator:interpolator:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.6.2
androidx.lifecycle:lifecycle-common:2.6.2
androidx.lifecycle:lifecycle-livedata-core:2.6.2
androidx.lifecycle:lifecycle-livedata:2.6.2
androidx.lifecycle:lifecycle-process:2.6.2
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2
androidx.lifecycle:lifecycle-runtime:2.6.2
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2
androidx.lifecycle:lifecycle-viewmodel:2.6.2
androidx.lifecycle:lifecycle-common-java8:2.8.0
androidx.lifecycle:lifecycle-common-jvm:2.8.0
androidx.lifecycle:lifecycle-common:2.8.0
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.0
androidx.lifecycle:lifecycle-livedata-core:2.8.0
androidx.lifecycle:lifecycle-livedata:2.8.0
androidx.lifecycle:lifecycle-process:2.8.0
androidx.lifecycle:lifecycle-runtime-android:2.8.0
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.0
androidx.lifecycle:lifecycle-runtime-compose:2.8.0
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.0
androidx.lifecycle:lifecycle-runtime-ktx:2.8.0
androidx.lifecycle:lifecycle-runtime:2.8.0
androidx.lifecycle:lifecycle-viewmodel-android:2.8.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.0
androidx.lifecycle:lifecycle-viewmodel:2.8.0
androidx.loader:loader:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing:1.0.0
androidx.tracing:tracing-ktx:1.3.0-alpha02
androidx.tracing:tracing:1.3.0-alpha02
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0-beta02
androidx.window:window-core:1.3.0-beta02
androidx.window:window:1.3.0-beta02
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.50
com.google.dagger:dagger:2.50
com.google.dagger:hilt-android:2.50
com.google.dagger:hilt-core:2.50
com.google.dagger:dagger-lint-aar:2.51.1
com.google.dagger:dagger:2.51.1
com.google.dagger:hilt-android:2.51.1
com.google.dagger:hilt-core:2.51.1
com.google.guava:listenablefuture:1.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.6.0
com.squareup.okio:okio:3.6.0
io.coil-kt:coil-base:2.5.0
io.coil-kt:coil-compose-base:2.5.0
io.coil-kt:coil-compose:2.5.0
io.coil-kt:coil:2.5.0
com.squareup.okio:okio-jvm:3.8.0
com.squareup.okio:okio:3.8.0
io.coil-kt:coil-base:2.6.0
io.coil-kt:coil-compose-base:2.6.0
io.coil-kt:coil-compose:2.6.0
io.coil-kt:coil:2.6.0
javax.inject:javax.inject:1
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.21
org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.0.0
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3

@ -1,3 +1,3 @@
# :app module
![Dependency graph](../docs/images/graphs/dep_graph_app.png)
## Dependency graph
![Dependency graph](../docs/images/graphs/dep_graph_app.svg)

@ -21,7 +21,6 @@ plugins {
alias(libs.plugins.nowinandroid.android.application.flavors)
alias(libs.plugins.nowinandroid.android.application.jacoco)
alias(libs.plugins.nowinandroid.android.hilt)
id("jacoco")
alias(libs.plugins.nowinandroid.android.application.firebase)
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.baselineprofile)
@ -45,7 +44,7 @@ android {
debug {
applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix
}
val release = getByName("release") {
release {
isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@ -53,21 +52,10 @@ android {
// 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.
// TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.named("debug").get()
// Ensure Baseline Profile is fresh for release builds.
baselineProfile.automaticGenerationDuringBuild = true
}
create("benchmark") {
// Enable all the optimizations from release build through initWith(release).
initWith(release)
matchingFallbacks.add("release")
// Debug key signing is available to everyone.
signingConfig = signingConfigs.getByName("debug")
// Only use benchmark proguard rules
proguardFiles("benchmark-rules.pro")
isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.BENCHMARK.applicationIdSuffix
}
}
packaging {
@ -100,16 +88,24 @@ dependencies {
implementation(projects.sync.work)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.window.core)
implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt)
ksp(libs.hilt.compiler)
debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(projects.uiTestHiltManifest)
@ -117,18 +113,21 @@ dependencies {
testImplementation(projects.core.dataTest)
testImplementation(projects.core.testing)
testImplementation(libs.accompanist.testharness)
testImplementation(projects.sync.syncTest)
testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.androidx.work.testing)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.work.testing)
testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi)
testDemoImplementation(projects.core.screenshotTesting)
androidTestImplementation(projects.core.testing)
androidTestImplementation(projects.core.dataTest)
androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness)
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.hilt.android.testing)
baselineProfile(projects.benchmarks)

@ -1,57 +1,68 @@
androidx.activity:activity-compose:1.8.0
androidx.activity:activity-ktx:1.8.0
androidx.activity:activity:1.8.0
androidx.annotation:annotation-experimental:1.3.1
androidx.annotation:annotation-jvm:1.7.0
androidx.annotation:annotation:1.7.0
androidx.appcompat:appcompat-resources:1.6.1
androidx.appcompat:appcompat:1.6.1
androidx.activity:activity-compose:1.8.2
androidx.activity:activity-ktx:1.8.2
androidx.activity:activity:1.8.2
androidx.annotation:annotation-experimental:1.4.0
androidx.annotation:annotation-jvm:1.8.0
androidx.annotation:annotation:1.8.0
androidx.appcompat:appcompat-resources:1.7.0
androidx.appcompat:appcompat:1.7.0
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.6.0
androidx.collection:collection-jvm:1.3.0
androidx.collection:collection-ktx:1.3.0
androidx.collection:collection:1.3.0
androidx.compose.animation:animation-android:1.5.4
androidx.compose.animation:animation-core-android:1.5.4
androidx.compose.animation:animation-core:1.5.4
androidx.compose.animation:animation:1.5.4
androidx.compose.foundation:foundation-android:1.5.4
androidx.compose.foundation:foundation-layout-android:1.5.4
androidx.compose.foundation:foundation-layout:1.5.4
androidx.compose.foundation:foundation:1.5.4
androidx.compose.material3:material3-window-size-class:1.1.2
androidx.compose.material3:material3:1.1.2
androidx.compose.material:material-icons-core-android:1.5.4
androidx.compose.material:material-icons-core:1.5.4
androidx.compose.material:material-icons-extended-android:1.5.4
androidx.compose.material:material-icons-extended:1.5.4
androidx.compose.material:material-ripple-android:1.5.4
androidx.compose.material:material-ripple:1.5.4
androidx.compose.runtime:runtime-android:1.5.4
androidx.compose.runtime:runtime-saveable-android:1.5.4
androidx.compose.runtime:runtime-saveable:1.5.4
androidx.compose.runtime:runtime:1.5.4
androidx.compose.ui:ui-android:1.5.4
androidx.compose.ui:ui-geometry-android:1.5.4
androidx.compose.ui:ui-geometry:1.5.4
androidx.compose.ui:ui-graphics-android:1.5.4
androidx.compose.ui:ui-graphics:1.5.4
androidx.compose.ui:ui-text-android:1.5.4
androidx.compose.ui:ui-text:1.5.4
androidx.compose.ui:ui-tooling-preview-android:1.5.4
androidx.compose.ui:ui-tooling-preview:1.5.4
androidx.compose.ui:ui-unit-android:1.5.4
androidx.compose.ui:ui-unit:1.5.4
androidx.compose.ui:ui-util-android:1.5.4
androidx.compose.ui:ui-util:1.5.4
androidx.compose.ui:ui:1.5.4
androidx.compose:compose-bom:2023.10.01
androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0
androidx.collection:collection-ktx:1.4.0
androidx.collection:collection:1.4.0
androidx.compose.animation:animation-android:1.7.0-beta01
androidx.compose.animation:animation-core-android:1.7.0-beta01
androidx.compose.animation:animation-core:1.7.0-beta01
androidx.compose.animation:animation:1.7.0-beta01
androidx.compose.foundation:foundation-android:1.7.0-beta01
androidx.compose.foundation:foundation-layout-android:1.7.0-beta01
androidx.compose.foundation:foundation-layout:1.7.0-beta01
androidx.compose.foundation:foundation:1.7.0-beta01
androidx.compose.material3.adaptive:adaptive-android:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive-layout:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-beta01
androidx.compose.material3.adaptive:adaptive:1.0.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.0-beta01
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.0-beta01
androidx.compose.material3:material3-android:1.3.0-beta01
androidx.compose.material3:material3-window-size-class-android:1.3.0-beta01
androidx.compose.material3:material3-window-size-class:1.3.0-beta01
androidx.compose.material3:material3:1.3.0-beta01
androidx.compose.material:material-icons-core-android:1.6.3
androidx.compose.material:material-icons-core:1.6.3
androidx.compose.material:material-icons-extended-android:1.6.3
androidx.compose.material:material-icons-extended:1.6.3
androidx.compose.material:material-ripple-android:1.7.0-beta01
androidx.compose.material:material-ripple:1.7.0-beta01
androidx.compose.runtime:runtime-android:1.7.0-beta01
androidx.compose.runtime:runtime-saveable-android:1.7.0-beta01
androidx.compose.runtime:runtime-saveable:1.7.0-beta01
androidx.compose.runtime:runtime-tracing:1.0.0-beta01
androidx.compose.runtime:runtime:1.7.0-beta01
androidx.compose.ui:ui-android:1.7.0-beta01
androidx.compose.ui:ui-geometry-android:1.7.0-beta01
androidx.compose.ui:ui-geometry:1.7.0-beta01
androidx.compose.ui:ui-graphics-android:1.7.0-beta01
androidx.compose.ui:ui-graphics:1.7.0-beta01
androidx.compose.ui:ui-text-android:1.7.0-beta01
androidx.compose.ui:ui-text:1.7.0-beta01
androidx.compose.ui:ui-tooling-preview-android:1.7.0-beta01
androidx.compose.ui:ui-tooling-preview:1.7.0-beta01
androidx.compose.ui:ui-unit-android:1.7.0-beta01
androidx.compose.ui:ui-unit:1.7.0-beta01
androidx.compose.ui:ui-util-android:1.7.0-beta01
androidx.compose.ui:ui-util:1.7.0-beta01
androidx.compose.ui:ui:1.7.0-beta01
androidx.compose:compose-bom:2024.02.02
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0
androidx.core:core-ktx:1.13.1
androidx.core:core-splashscreen:1.0.1
androidx.core:core:1.12.0
androidx.core:core:1.13.1
androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0
@ -61,37 +72,45 @@ androidx.datastore:datastore-preferences:1.0.0
androidx.datastore:datastore:1.0.0
androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.0.0
androidx.emoji2:emoji2-views-helper:1.4.0
androidx.emoji2:emoji2:1.4.0
androidx.exifinterface:exifinterface:1.3.6
androidx.fragment:fragment:1.5.1
androidx.emoji2:emoji2-views-helper:1.3.0
androidx.emoji2:emoji2:1.3.0
androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.4
androidx.graphics:graphics-path:1.0.1
androidx.hilt:hilt-common:1.1.0
androidx.hilt:hilt-navigation-compose:1.0.0
androidx.hilt:hilt-navigation:1.0.0
androidx.hilt:hilt-navigation-compose:1.2.0
androidx.hilt:hilt-navigation:1.2.0
androidx.hilt:hilt-work:1.1.0
androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.6.2
androidx.lifecycle:lifecycle-common:2.6.2
androidx.lifecycle:lifecycle-livedata-core:2.6.2
androidx.lifecycle:lifecycle-livedata:2.6.2
androidx.lifecycle:lifecycle-process:2.6.2
androidx.lifecycle:lifecycle-runtime-compose:2.6.2
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2
androidx.lifecycle:lifecycle-runtime:2.6.2
androidx.lifecycle:lifecycle-service:2.6.2
androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2
androidx.lifecycle:lifecycle-viewmodel:2.6.2
androidx.lifecycle:lifecycle-common-java8:2.8.1
androidx.lifecycle:lifecycle-common-jvm:2.8.1
androidx.lifecycle:lifecycle-common:2.8.1
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.1
androidx.lifecycle:lifecycle-livedata-core:2.8.1
androidx.lifecycle:lifecycle-livedata:2.8.1
androidx.lifecycle:lifecycle-process:2.8.1
androidx.lifecycle:lifecycle-runtime-android:2.8.1
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.1
androidx.lifecycle:lifecycle-runtime-compose:2.8.1
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.1
androidx.lifecycle:lifecycle-runtime-ktx:2.8.1
androidx.lifecycle:lifecycle-runtime:2.8.1
androidx.lifecycle:lifecycle-service:2.8.1
androidx.lifecycle:lifecycle-viewmodel-android:2.8.1
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.8.1
androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.1
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.1
androidx.lifecycle:lifecycle-viewmodel:2.8.1
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.navigation:navigation-common-ktx:2.7.4
androidx.navigation:navigation-common:2.7.4
androidx.navigation:navigation-compose:2.7.4
androidx.navigation:navigation-runtime-ktx:2.7.4
androidx.navigation:navigation-runtime:2.7.4
androidx.navigation:navigation-common-ktx:2.8.0-alpha06
androidx.navigation:navigation-common:2.8.0-alpha06
androidx.navigation:navigation-compose:2.8.0-alpha06
androidx.navigation:navigation-runtime-ktx:2.8.0-alpha06
androidx.navigation:navigation-runtime:2.8.0-alpha06
androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
@ -105,18 +124,22 @@ androidx.savedstate:savedstate:1.2.1
androidx.sqlite:sqlite-framework:2.4.0
androidx.sqlite:sqlite:2.4.0
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.1.0
androidx.tracing:tracing:1.1.0
androidx.tracing:tracing-ktx:1.3.0-alpha02
androidx.tracing:tracing-perfetto:1.0.0
androidx.tracing:tracing:1.3.0-alpha02
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window:window:1.0.0
androidx.work:work-runtime-ktx:2.9.0-rc01
androidx.work:work-runtime:2.9.0-rc01
androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0-beta02
androidx.window:window-core:1.3.0-beta02
androidx.window:window:1.3.0-beta02
androidx.work:work-runtime-ktx:2.9.0
androidx.work:work-runtime:2.9.0
com.caverock:androidsvg-aar:1.4
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.accompanist:accompanist-permissions:0.32.0
com.google.accompanist:accompanist-permissions:0.34.0
com.google.android.datatransport:transport-api:3.0.0
com.google.android.datatransport:transport-backend-cct:3.1.9
com.google.android.datatransport:transport-runtime:3.1.9
@ -134,10 +157,10 @@ com.google.android.gms:play-services-oss-licenses:17.0.1
com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.0.2
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.50
com.google.dagger:dagger:2.50
com.google.dagger:hilt-android:2.50
com.google.dagger:hilt-core:2.50
com.google.dagger:dagger-lint-aar:2.51.1
com.google.dagger:dagger:2.51.1
com.google.dagger:hilt-android:2.51.1
com.google.dagger:hilt-core:2.51.1
com.google.errorprone:error_prone_annotations:2.11.0
com.google.firebase:firebase-abt:21.1.1
com.google.firebase:firebase-analytics-ktx:21.4.0
@ -168,38 +191,36 @@ com.google.guava:failureaccess:1.0.1
com.google.guava:guava:31.1-android
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:1.3
com.google.protobuf:protobuf-javalite:3.24.4
com.google.protobuf:protobuf-kotlin-lite:3.24.4
com.google.protobuf:protobuf-javalite:4.26.1
com.google.protobuf:protobuf-kotlin-lite:4.26.1
com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0
com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.6.0
com.squareup.okio:okio:3.6.0
com.squareup.okio:okio-jvm:3.8.0
com.squareup.okio:okio:3.8.0
com.squareup.retrofit2:retrofit:2.9.0
io.coil-kt:coil-base:2.5.0
io.coil-kt:coil-compose-base:2.5.0
io.coil-kt:coil-compose:2.5.0
io.coil-kt:coil-svg:2.5.0
io.coil-kt:coil:2.5.0
io.github.aakira:napier-android:1.4.1
io.github.aakira:napier:1.4.1
io.coil-kt:coil-base:2.6.0
io.coil-kt:coil-compose-base:2.6.0
io.coil-kt:coil-compose:2.6.0
io.coil-kt:coil-svg:2.6.0
io.coil-kt:coil:2.6.0
javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.21
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3
org.jetbrains.kotlin:kotlin-stdlib-common:2.0.0
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.0.0
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.8.0
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.5.0
org.jetbrains.kotlinx:kotlinx-datetime:0.5.0
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0
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:1.6.3
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.3
org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3
org.jetbrains:annotations:23.0.0

@ -6,11 +6,11 @@ uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
uses-permission: name='android.permission.POST_NOTIFICATIONS'
uses-permission: name='android.permission.WAKE_LOCK'
uses-permission: name='com.google.android.c2dm.permission.RECEIVE'
uses-permission: name='android.permission.RECEIVE_BOOT_COMPLETED'
uses-permission: name='android.permission.FOREGROUND_SERVICE'
uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE'
uses-permission: name='android.permission.ACCESS_ADSERVICES_ATTRIBUTION'
uses-permission: name='android.permission.ACCESS_ADSERVICES_AD_ID'
uses-permission: name='android.permission.RECEIVE_BOOT_COMPLETED'
uses-permission: name='android.permission.FOREGROUND_SERVICE'
uses-permission: name='com.google.samples.apps.nowinandroid.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
application-label:'Now in Android'
application-label-af:'Now in Android'
@ -108,9 +108,9 @@ application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
application: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon=''
property: name='android.adservices.AD_SERVICES_CONFIG' resource='res/xml/ga_ad_services_config.xml'
uses-library-not-required:'android.ext.adservices'
uses-library-not-required:'androidx.window.extensions'
uses-library-not-required:'androidx.window.sidecar'
uses-library-not-required:'android.ext.adservices'
feature-group: label=''
uses-feature: name='android.hardware.faketouch'
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'
@ -122,3 +122,4 @@ supports-screens: 'small' 'normal' 'large' 'xlarge'
supports-any-density: 'true'
locales: '--_--' 'af' 'am' 'ar' 'as' 'az' 'be' 'bg' 'bn' 'bs' 'ca' 'cs' 'da' 'de' 'el' 'en-AU' 'en-CA' 'en-GB' 'en-IN' 'en-XC' 'es' 'es-US' 'et' 'eu' 'fa' 'fi' 'fr' 'fr-CA' 'gl' 'gu' 'hi' 'hr' 'hu' 'hy' 'in' 'is' 'it' 'iw' 'ja' 'ka' 'kk' 'km' 'kn' 'ko' 'ky' 'lo' 'lt' 'lv' 'mk' 'ml' 'mn' 'mr' 'ms' 'my' 'nb' 'ne' 'nl' 'or' 'pa' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'si' 'sk' 'sl' 'sq' 'sr' 'sr-Latn' 'sv' 'sw' 'ta' 'te' 'th' 'tl' 'tr' 'uk' 'ur' 'uz' 'vi' 'zh-CN' 'zh-HK' 'zh-TW' 'zu'
densities: '120' '160' '240' '320' '480' '640' '65534'
native-code: 'arm64-v8a' 'armeabi-v7a' 'x86' 'x86_64'

@ -1,13 +1,3 @@
-dontwarn org.bouncycastle.jsse.BCSSLParameters
-dontwarn org.bouncycastle.jsse.BCSSLSocket
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
-dontwarn org.conscrypt.Conscrypt$Version
-dontwarn org.conscrypt.Conscrypt
-dontwarn org.conscrypt.ConscryptHostnameVerifier
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
-dontwarn org.openjsse.net.ssl.OpenJSSE
# 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
@ -16,4 +6,4 @@
# 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
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation

@ -20,7 +20,6 @@ import androidx.annotation.StringRes
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@ -90,7 +89,7 @@ class NavigationTest {
lateinit var topicsRepository: TopicsRepository
private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
ReadOnlyProperty<Any?, String> { _, _ -> activity.getString(resId) }
ReadOnlyProperty<Any, String> { _, _ -> activity.getString(resId) }
// The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up)
@ -225,12 +224,7 @@ class NavigationTest {
onNodeWithText(ok).performClick()
// Check that the saved screen is still visible and selected.
onNode(
hasText(saved) and
hasAnyAncestor(
hasTestTag("NiaBottomBar") or hasTestTag("NiaNavRail"),
),
).assertIsSelected()
onNode(hasText(saved) and hasTestTag("NiaNavItem")).assertIsSelected()
}
}
@ -274,10 +268,10 @@ class NavigationTest {
// Select the last topic
val topic = runBlocking {
topicsRepository.getTopics().first().sortedBy(Topic::name).last().name
topicsRepository.getTopics().first().sortedBy(Topic::name).last()
}
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic))
onNodeWithText(topic).performClick()
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic.name))
onNodeWithText(topic.name).performClick()
// Switch tab
onNodeWithText(forYou).performClick()
@ -285,8 +279,8 @@ class NavigationTest {
// Come back to Interests
onNodeWithText(interests).performClick()
// Verify we're not in the list of interests
onNodeWithTag("interests:topics").assertDoesNotExist()
// Verify the topic is still shown
onNodeWithTag("topic:${topic.id}").assertExists()
}
}
}

@ -1,268 +0,0 @@
/*
* Copyright 2022 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.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.accompanist.testharness.TestHarness
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import javax.inject.Inject
/**
* Tests that the navigation UI is rendered correctly on different screen sizes.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@HiltAndroidTest
class NavigationUiTest {
/**
* Manages the components' state and is used to perform injection on your test
*/
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
/**
* Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash.
*/
@BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
* Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
*/
@get:Rule(order = 2)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = TestNewsRepository(),
userDataRepository = TestUserDataRepository(),
)
@Inject
lateinit var networkMonitor: NetworkMonitor
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun compactWidth_compactHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_mediumHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_expandedHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
}

@ -16,15 +16,11 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable
@ -34,14 +30,15 @@ import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNe
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.TimeZone
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
@ -50,7 +47,6 @@ import kotlin.test.assertTrue
* Note: This could become an unit test if Robolectric is added to the project and the Context
* is faked.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class NiaAppStateTest {
@get:Rule
@ -59,6 +55,8 @@ class NiaAppStateTest {
// Create the test dependencies.
private val networkMonitor = TestNetworkMonitor()
private val timeZoneMonitor = TestTimeZoneMonitor()
private val userNewsResourceRepository =
CompositeUserNewsResourceRepository(TestNewsRepository(), TestUserDataRepository())
@ -75,9 +73,9 @@ class NiaAppStateTest {
NiaAppState(
navController = navController,
coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
@ -97,9 +95,9 @@ class NiaAppStateTest {
fun niaAppState_destinations() = runTest {
composeTestRule.setContent {
state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
@ -110,80 +108,50 @@ class NiaAppStateTest {
}
@Test
fun niaAppState_showBottomBar_compact() = runTest {
fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
assertTrue(state.shouldShowBottomBar)
assertFalse(state.shouldShowNavRail)
}
@Test
fun niaAppState_showNavRail_medium() = runTest {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
assertTrue(state.shouldShowNavRail)
assertFalse(state.shouldShowBottomBar)
}
@Test
fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
assertTrue(state.shouldShowNavRail)
assertFalse(state.shouldShowBottomBar)
backgroundScope.launch { state.isOffline.collect() }
networkMonitor.setConnected(false)
assertEquals(
true,
state.isOffline.value,
)
}
@Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
backgroundScope.launch { state.isOffline.collect() }
networkMonitor.setConnected(false)
val changedTz = TimeZone.of("Europe/Prague")
backgroundScope.launch { state.currentTimeZone.collect() }
timeZoneMonitor.setTimeZone(changedTz)
assertEquals(
true,
state.isOffline.value,
changedTz,
state.currentTimeZone.value,
)
}
private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp))
}
@Composable
private fun rememberTestNavController(): TestNavHostController {
val context = LocalContext.current
return remember<TestNavHostController> {
return remember {
TestNavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavigator())
graph = createGraph(startDestination = "a") {

@ -17,15 +17,13 @@
package com.google.samples.apps.nowinandroid
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
@ -34,32 +32,31 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.metrics.performance.JankStats
import androidx.profileinstaller.ProfileVerifier
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone
import com.google.samples.apps.nowinandroid.ui.NiaApp
import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
private const val TAG = "MainActivity"
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@ -72,6 +69,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
@Inject
lateinit var analyticsHelper: AnalyticsHelper
@ -90,9 +90,7 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.onEach {
uiState = it
}
.onEach { uiState = it }
.collect()
}
}
@ -133,17 +131,25 @@ class MainActivity : ComponentActivity() {
onDispose {}
}
CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) {
val appState = rememberNiaAppState(
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle()
CompositionLocalProvider(
LocalAnalyticsHelper provides analyticsHelper,
LocalTimeZone provides currentTimeZone,
) {
NiaTheme(
darkTheme = darkTheme,
androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) {
NiaApp(
networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this),
userNewsResourceRepository = userNewsResourceRepository,
)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
NiaApp(appState)
}
}
}
@ -152,48 +158,12 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
lazyStats.get().isTrackingEnabled = true
lifecycleScope.launch {
logCompilationStatus()
}
}
override fun onPause() {
super.onPause()
lazyStats.get().isTrackingEnabled = false
}
/**
* Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].
*/
private suspend fun logCompilationStatus() {
/*
When delivering through Google Play, the baseline profile is compiled during installation.
In this case you will see the correct state logged without any further action necessary.
To verify baseline profile installation locally, you need to manually trigger baseline
profile installation.
For immediate compilation, call:
`adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target`
You can also trigger background optimizations:
`adb shell pm bg-dexopt-job`
Both jobs run asynchronously and might take some time complete.
To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
If you don't do either of these steps, you might only see the profile status reported as
"enqueued for compilation" when running the sample locally.
*/
withContext(Dispatchers.IO) {
val status = ProfileVerifier.getCompilationStatusAsync().await()
Log.d(TAG, "ProfileInstaller status code: ${status.profileInstallResultCode}")
Log.d(
TAG,
when {
status.isCompiledWithProfile -> "ProfileInstaller: is compiled with profile"
status.hasProfileEnqueuedForCompilation() ->
"ProfileInstaller: Enqueued for compilation"
else -> "Profile not compiled or enqueued"
},
)
}
}
}
/**

@ -20,9 +20,9 @@ import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import com.google.samples.apps.nowinandroid.sync.initializers.Sync
import com.google.samples.apps.nowinandroid.util.ProfileVerifierLogger
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import javax.inject.Provider
/**
* [Application] class for NiA
@ -30,12 +30,16 @@ import javax.inject.Provider
@HiltAndroidApp
class NiaApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var imageLoader: Provider<ImageLoader>
lateinit var imageLoader: dagger.Lazy<ImageLoader>
@Inject
lateinit var profileVerifierLogger: ProfileVerifierLogger
override fun onCreate() {
super.onCreate()
// Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this)
profileVerifierLogger()
}
override fun newImageLoader(): ImageLoader = imageLoader.get()

@ -20,6 +20,7 @@ import android.app.Activity
import android.util.Log
import android.view.Window
import androidx.metrics.performance.JankStats
import androidx.metrics.performance.JankStats.OnFrameListener
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -29,26 +30,20 @@ import dagger.hilt.android.components.ActivityComponent
@InstallIn(ActivityComponent::class)
object JankStatsModule {
@Provides
fun providesOnFrameListener(): JankStats.OnFrameListener {
return JankStats.OnFrameListener { frameData ->
// Make sure to only log janky frames.
if (frameData.isJank) {
// We're currently logging this but would better report it to a backend.
Log.v("NiA Jank", frameData.toString())
}
fun providesOnFrameListener(): OnFrameListener = OnFrameListener { frameData ->
// Make sure to only log janky frames.
if (frameData.isJank) {
// We're currently logging this but would better report it to a backend.
Log.v("NiA Jank", frameData.toString())
}
}
@Provides
fun providesWindow(activity: Activity): Window {
return activity.window
}
fun providesWindow(activity: Activity): Window = activity.window
@Provides
fun providesJankStats(
window: Window,
frameListener: JankStats.OnFrameListener,
): JankStats {
return JankStats.createAndTrack(window, frameListener)
}
frameListener: OnFrameListener,
): JankStats = JankStats.createAndTrack(window, frameListener)
}

@ -22,12 +22,11 @@ import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
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.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen
/**
* Top-level navigation graph. Navigation is organized as explained at
@ -49,24 +48,16 @@ fun NiaNavHost(
startDestination = startDestination,
modifier = modifier,
) {
forYouScreen(onTopicClick = navController::navigateToTopic)
forYouScreen(onTopicClick = navController::navigateToInterests)
bookmarksScreen(
onTopicClick = navController::navigateToTopic,
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToTopic,
)
interestsGraph(
onTopicClick = navController::navigateToTopic,
nestedGraphs = {
topicScreen(
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
},
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
}

@ -16,9 +16,8 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
@ -26,7 +25,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -39,7 +37,9 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -62,14 +62,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBar
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationBarItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRailItem
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationSuiteScaffold
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
@ -79,29 +74,18 @@ import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class,
)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun NiaApp(
windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
windowSizeClass = windowSizeClass,
userNewsResourceRepository = userNewsResourceRepository,
),
appState: NiaAppState,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
var showSettingsDialog by rememberSaveable {
mutableStateOf(false)
}
var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
NiaBackground {
NiaBackground(modifier = modifier) {
NiaGradientBackground(
gradientColors = if (shouldShowGradientBackground) {
LocalGradientColors.current
@ -124,162 +108,141 @@ fun NiaApp(
}
}
if (showSettingsDialog) {
SettingsDialog(
onDismiss = { showSettingsDialog = false },
)
}
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = showSettingsDialog,
onSettingsDismissed = { showSettingsDialog = false },
onTopAppBarActionClick = { showSettingsDialog = true },
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
}
}
@Composable
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::class,
ExperimentalMaterial3AdaptiveApi::class,
)
internal fun NiaApp(
appState: NiaAppState,
snackbarHostState: SnackbarHostState,
showSettingsDialog: Boolean,
onSettingsDismissed: () -> Unit,
onTopAppBarActionClick: () -> Unit,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources
.collectAsStateWithLifecycle()
val currentDestination = appState.currentDestination
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
if (showSettingsDialog) {
SettingsDialog(
onDismiss = { onSettingsDismissed() },
)
}
Scaffold(
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"),
NiaNavigationSuiteScaffold(
navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination
.isTopLevelDestinationInHierarchy(destination)
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
}
},
) { padding ->
Row(
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal,
),
.testTag("NiaNavItem")
.then(if (hasUnread) Modifier.notificationDot() else Modifier),
)
}
},
windowAdaptiveInfo = windowAdaptiveInfo,
) {
Scaffold(
modifier = modifier.semantics {
testTagsAsResourceId = true
},
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal,
),
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding(),
)
}
Column(Modifier.fillMaxSize()) {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
if (destination != null) {
NiaTopAppBar(
titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = settingsR.string.feature_settings_top_app_bar_navigation_icon_description,
),
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.feature_settings_top_app_bar_action_icon_description,
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
),
onActionClick = { showSettingsDialog = true },
onNavigationClick = { appState.navigateToSearch() },
)
}
),
) {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
val shouldShowTopAppBar = destination != null
if (destination != null) {
NiaTopAppBar(
titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = settingsR.string.feature_settings_top_app_bar_navigation_icon_description,
),
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.feature_settings_top_app_bar_action_icon_description,
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
),
onActionClick = { onTopAppBarActionClick() },
onNavigationClick = { appState.navigateToSearch() },
)
}
NiaNavHost(appState = appState, onShowSnackbar = { message, action ->
Box(
// Workaround for https://issuetracker.google.com/338478720
modifier = Modifier.consumeWindowInsets(
if (shouldShowTopAppBar) {
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
} else {
WindowInsets(0, 0, 0, 0)
},
),
) {
NiaNavHost(
appState = appState,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
})
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
}
}
}
}
}
@Composable
private fun NiaNavRail(
destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?,
modifier: Modifier = Modifier,
) {
NiaNavigationRail(modifier = modifier) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
val hasUnread = destinationsWithUnreadResources.contains(destination)
NiaNavigationRailItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
},
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) Modifier.notificationDot() else Modifier,
)
}
}
}
}
@Composable
private fun NiaBottomBar(
destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?,
modifier: Modifier = Modifier,
) {
NiaNavigationBar(
modifier = modifier,
) {
destinations.forEach { destination ->
val hasUnread = destinationsWithUnreadResources.contains(destination)
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
NiaNavigationBarItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier = if (hasUnread) Modifier.notificationDot() else Modifier,
)
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
}
}
}
}

@ -16,8 +16,6 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
@ -32,13 +30,14 @@ import androidx.navigation.navOptions
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BOOKMARKS_ROUTE
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
@ -50,12 +49,13 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.datetime.TimeZone
@Composable
fun rememberNiaAppState(
windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): NiaAppState {
@ -63,16 +63,16 @@ fun rememberNiaAppState(
return remember(
navController,
coroutineScope,
windowSizeClass,
networkMonitor,
userNewsResourceRepository,
timeZoneMonitor,
) {
NiaAppState(
navController,
coroutineScope,
windowSizeClass,
networkMonitor,
userNewsResourceRepository,
navController = navController,
coroutineScope = coroutineScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}
}
@ -80,10 +80,10 @@ fun rememberNiaAppState(
@Stable
class NiaAppState(
val navController: NavHostController,
val coroutineScope: CoroutineScope,
val windowSizeClass: WindowSizeClass,
coroutineScope: CoroutineScope,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
) {
val currentDestination: NavDestination?
@Composable get() = navController
@ -97,12 +97,6 @@ class NiaAppState(
else -> null
}
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
@ -127,12 +121,20 @@ class NiaAppState(
FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
)
}.stateIn(
}
.stateIn(
coroutineScope,
SharingStarted.WhileSubscribed(5_000),
initialValue = emptySet(),
)
val currentTimeZone = timeZoneMonitor.currentTimeZone
.stateIn(
coroutineScope,
SharingStarted.WhileSubscribed(5_000),
TimeZone.currentSystemDefault(),
)
/**
* UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you
@ -159,14 +161,12 @@ class NiaAppState(
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterestsGraph(topLevelNavOptions)
INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions)
}
}
}
fun navigateToSearch() {
navController.navigateToSearch()
}
fun navigateToSearch() = navController.navigateToSearch()
}
/**

@ -0,0 +1,36 @@
/*
* 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.ui.interests2pane
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
@HiltViewModel
class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val selectedTopicId: StateFlow<String?> =
savedStateHandle.getStateFlow(TOPIC_ID_ARG, savedStateHandle[TOPIC_ID_ARG])
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_ARG] = topicId
}
}

@ -0,0 +1,168 @@
/*
* 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.ui.interests2pane
import androidx.activity.compose.BackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE
import com.google.samples.apps.nowinandroid.feature.topic.navigation.createTopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import java.util.UUID
private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route"
fun NavGraphBuilder.interestsListDetailScreen() {
composable(
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
type = NavType.StringType
defaultValue = null
nullable = true
},
),
) {
InterestsListDetailScreen()
}
}
@Composable
internal fun InterestsListDetailScreen(
viewModel: Interests2PaneViewModel = hiltViewModel(),
) {
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
InterestsListDetailScreen(
selectedTopicId = selectedTopicId,
onTopicClick = viewModel::onTopicClick,
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
internal fun InterestsListDetailScreen(
selectedTopicId: String?,
onTopicClick: (String) -> Unit,
) {
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator(
initialDestinationHistory = listOfNotNull(
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
ThreePaneScaffoldDestinationItem<Nothing>(ListDetailPaneScaffoldRole.Detail).takeIf {
selectedTopicId != null
},
),
)
BackHandler(listDetailNavigator.canNavigateBack()) {
listDetailNavigator.navigateBack()
}
var nestedNavHostStartDestination by remember {
mutableStateOf(selectedTopicId?.let(::createTopicRoute) ?: TOPIC_ROUTE)
}
var nestedNavKey by rememberSaveable(
stateSaver = Saver({ it.toString() }, UUID::fromString),
) {
mutableStateOf(UUID.randomUUID())
}
val nestedNavController = key(nestedNavKey) {
rememberNavController()
}
fun onTopicClickShowDetailPane(topicId: String) {
onTopicClick(topicId)
if (listDetailNavigator.isDetailPaneVisible()) {
// If the detail pane was visible, then use the nestedNavController navigate call
// directly
nestedNavController.navigateToTopic(topicId) {
popUpTo(DETAIL_PANE_NAVHOST_ROUTE)
}
} else {
// Otherwise, recreate the NavHost entirely, and start at the new destination
nestedNavHostStartDestination = createTopicRoute(topicId)
nestedNavKey = UUID.randomUUID()
}
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
ListDetailPaneScaffold(
value = listDetailNavigator.scaffoldValue,
directive = listDetailNavigator.scaffoldDirective,
listPane = {
AnimatedPane {
InterestsRoute(
onTopicClick = ::onTopicClickShowDetailPane,
highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
)
}
},
detailPane = {
AnimatedPane {
key(nestedNavKey) {
NavHost(
navController = nestedNavController,
startDestination = nestedNavHostStartDestination,
route = DETAIL_PANE_NAVHOST_ROUTE,
) {
topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane,
)
composable(route = TOPIC_ROUTE) {
TopicDetailPlaceholder()
}
}
}
}
},
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isListPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isDetailPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded

@ -0,0 +1,70 @@
/*
* Copyright 2023 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.util
import android.util.Log
import androidx.profileinstaller.ProfileVerifier
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].
*
* When delivering through Google Play, the baseline profile is compiled during installation.
* In this case you will see the correct state logged without any further action necessary.
* To verify baseline profile installation locally, you need to manually trigger baseline
* profile installation.
*
* For immediate compilation, call:
* ```bash
* adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target
* ```
* You can also trigger background optimizations:
* ```bash
* adb shell pm bg-dexopt-job
* ```
* Both jobs run asynchronously and might take some time complete.
*
* To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
* If you don't do either of these steps, you might only see the profile status reported as
* "enqueued for compilation" when running the sample locally.
*
* @see androidx.profileinstaller.ProfileVerifier.CompilationStatus.ResultCode
*/
class ProfileVerifierLogger @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
) {
companion object {
private const val TAG = "ProfileInstaller"
}
operator fun invoke() = scope.launch {
val status = ProfileVerifier.getCompilationStatusAsync().await()
Log.d(TAG, "Status code: ${status.profileInstallResultCode}")
Log.d(
TAG,
when {
status.isCompiledWithProfile -> "App compiled with profile"
status.hasProfileEnqueuedForCompilation() -> "Profile enqueued for compilation"
else -> "Profile not compiled nor enqueued"
},
)
}
}

@ -20,10 +20,10 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M65.08,84.13C64.01,84.13 63.13,83.26 63.13,82.18C63.13,81.11 64,80.24 65.08,80.24C66.15,80.24 67.02,81.11 67.02,82.18C67.02,83.26 66.15,84.13 65.08,84.13ZM43.6,84.13C42.53,84.13 41.65,83.26 41.65,82.18C41.65,81.11 42.52,80.24 43.6,80.24C44.66,80.24 45.54,81.11 45.54,82.18C45.54,83.26 44.67,84.13 43.6,84.13ZM65.77,72.44L69.66,65.73C69.88,65.35 69.74,64.85 69.36,64.63C68.97,64.41 68.48,64.54 68.25,64.93L64.32,71.73C61.31,70.36 57.94,69.59 54.33,69.59C50.73,69.59 47.35,70.36 44.34,71.73L40.41,64.93C40.19,64.54 39.69,64.41 39.31,64.63C38.92,64.85 38.79,65.35 39.01,65.73L42.89,72.44C36.22,76.07 31.67,82.81 31,90.77H77.67C77,82.8 72.44,76.06 65.77,72.44Z"
android:pathData="M65.08,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM43.6,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM65.77,72.44 L69.66,65.73a0.81,0.81 0,0 0,-0.3 -1.1,0.82 0.82,0 0,0 -1.11,0.3l-3.93,6.8a24,24 0,0 0,-9.99 -2.14c-3.6,0 -6.98,0.77 -9.99,2.14l-3.93,-6.8a0.8,0.8 0,1 0,-1.4 0.8l3.88,6.71A22.91,22.91 0,0 0,31 90.77h46.67a22.9,22.9 0,0 0,-11.9 -18.33Z"
android:fillColor="@color/ic_launcher_foreground_tint"/>
<path
android:pathData="M46.57,35H46.57C46.1,35 45.72,35.38 45.72,35.85L45.72,43.15H44.19C43.35,43.15 42.67,43.83 42.67,44.68C42.67,45.52 43.35,46.2 44.19,46.2H45.72V43.15H47.42C48.17,43.15 48.78,42.54 48.78,41.79L48.78,37.72H49.97C50.43,37.72 50.81,37.34 50.81,36.87V35.85C50.81,35.38 50.43,35 49.97,35H47.42H46.57ZM46.57,54.35H46.57H47.42H49.97C50.43,54.35 50.81,53.97 50.81,53.5V52.48C50.81,52.02 50.43,51.64 49.97,51.64H48.78L48.78,47.56C48.78,46.81 48.17,46.2 47.42,46.2H45.72L45.72,53.5C45.72,53.97 46.1,54.35 46.57,54.35ZM61.54,35H61.54C62.01,35 62.39,35.38 62.39,35.85V43.15H63.92C64.76,43.15 65.44,43.83 65.44,44.68C65.44,45.52 64.76,46.2 63.92,46.2H62.39V43.15H60.69C59.94,43.15 59.33,42.54 59.33,41.79V37.72H58.15C57.68,37.72 57.3,37.34 57.3,36.87V35.85C57.3,35.38 57.68,35 58.15,35H60.69H61.54ZM61.54,54.35H61.54H60.69H58.15C57.68,54.35 57.3,53.97 57.3,53.5V52.48C57.3,52.02 57.68,51.64 58.15,51.64H59.33V47.56C59.33,46.81 59.94,46.2 60.69,46.2H62.39V53.5C62.39,53.97 62.01,54.35 61.54,54.35Z"
android:pathData="M46.57,35a0.85,0.85 0,0 0,-0.85 0.85v7.3h-1.53a1.52,1.52 0,0 0,0 3.05h1.53v-3.05h1.7c0.75,0 1.36,-0.61 1.36,-1.36v-4.07h1.19c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.85h-3.4ZM46.57,54.35h3.4c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.84h-1.19v-4.08c0,-0.75 -0.61,-1.36 -1.36,-1.36h-1.7v7.3c0,0.47 0.38,0.85 0.85,0.85ZM61.54,35c0.47,0 0.85,0.38 0.85,0.85v7.3h1.53a1.52,1.52 0,0 1,0 3.05h-1.53v-3.05h-1.7c-0.75,0 -1.36,-0.61 -1.36,-1.36v-4.07h-1.18a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.47 0.38,-0.85 0.85,-0.85h3.39ZM61.54,54.35h-3.39a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.46 0.38,-0.84 0.85,-0.84h1.18v-4.08c0,-0.75 0.61,-1.36 1.36,-1.36h1.7v7.3c0,0.47 -0.38,0.85 -0.85,0.85Z"
android:fillColor="@color/ic_launcher_foreground_tint"
android:fillType="evenOdd"/>
</vector>

@ -24,11 +24,11 @@
android:pathData="M0,0h108v108h-108z"
android:fillColor="@color/ic_launcher_background_tint"/>
<path
android:pathData="M65.08,84.13C64.01,84.13 63.13,83.26 63.13,82.18C63.13,81.11 64,80.24 65.08,80.24C66.15,80.24 67.02,81.11 67.02,82.18C67.02,83.26 66.15,84.13 65.08,84.13ZM43.6,84.13C42.53,84.13 41.65,83.26 41.65,82.18C41.65,81.11 42.52,80.24 43.6,80.24C44.66,80.24 45.54,81.11 45.54,82.18C45.54,83.26 44.67,84.13 43.6,84.13ZM65.77,72.44L69.66,65.73C69.88,65.35 69.74,64.85 69.36,64.63C68.97,64.41 68.48,64.54 68.25,64.93L64.32,71.73C61.31,70.36 57.94,69.59 54.33,69.59C50.73,69.59 47.35,70.36 44.34,71.73L40.41,64.93C40.19,64.54 39.69,64.41 39.31,64.63C38.92,64.85 38.79,65.35 39.01,65.73L42.89,72.44C36.22,76.07 31.67,82.81 31,90.77H77.67C77,82.8 72.44,76.06 65.77,72.44Z"
android:pathData="M65.08,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM43.6,84.13a1.94,1.94 0,1 1,-0.01 -3.9,1.94 1.94,0 0,1 0.01,3.9ZM65.77,72.44 L69.66,65.73a0.81,0.81 0,0 0,-0.3 -1.1,0.82 0.82,0 0,0 -1.11,0.3l-3.93,6.8a24,24 0,0 0,-9.99 -2.14c-3.6,0 -6.98,0.77 -9.99,2.14l-3.93,-6.8a0.8,0.8 0,1 0,-1.4 0.8l3.88,6.71A22.91,22.91 0,0 0,31 90.77h46.67a22.9,22.9 0,0 0,-11.9 -18.33Z"
android:fillColor="@color/ic_launcher_foreground_tint"/>
<path
android:pathData="M46.57,35H46.57C46.1,35 45.72,35.38 45.72,35.85L45.72,43.15H44.19C43.35,43.15 42.67,43.83 42.67,44.68C42.67,45.52 43.35,46.2 44.19,46.2H45.72V43.15H47.42C48.17,43.15 48.78,42.54 48.78,41.79L48.78,37.72H49.97C50.43,37.72 50.81,37.34 50.81,36.87V35.85C50.81,35.38 50.43,35 49.97,35H47.42H46.57ZM46.57,54.35H46.57H47.42H49.97C50.43,54.35 50.81,53.97 50.81,53.5V52.48C50.81,52.02 50.43,51.64 49.97,51.64H48.78L48.78,47.56C48.78,46.81 48.17,46.2 47.42,46.2H45.72L45.72,53.5C45.72,53.97 46.1,54.35 46.57,54.35ZM61.54,35H61.54C62.01,35 62.39,35.38 62.39,35.85V43.15H63.92C64.76,43.15 65.44,43.83 65.44,44.68C65.44,45.52 64.76,46.2 63.92,46.2H62.39V43.15H60.69C59.94,43.15 59.33,42.54 59.33,41.79V37.72H58.15C57.68,37.72 57.3,37.34 57.3,36.87V35.85C57.3,35.38 57.68,35 58.15,35H60.69H61.54ZM61.54,54.35H61.54H60.69H58.15C57.68,54.35 57.3,53.97 57.3,53.5V52.48C57.3,52.02 57.68,51.64 58.15,51.64H59.33V47.56C59.33,46.81 59.94,46.2 60.69,46.2H62.39V53.5C62.39,53.97 62.01,54.35 61.54,54.35Z"
android:pathData="M46.57,35a0.85,0.85 0,0 0,-0.85 0.85v7.3h-1.53a1.52,1.52 0,0 0,0 3.05h1.53v-3.05h1.7c0.75,0 1.36,-0.61 1.36,-1.36v-4.07h1.19c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.85h-3.4ZM46.57,54.35h3.4c0.46,0 0.84,-0.38 0.84,-0.85v-1.02a0.85,0.85 0,0 0,-0.84 -0.84h-1.19v-4.08c0,-0.75 -0.61,-1.36 -1.36,-1.36h-1.7v7.3c0,0.47 0.38,0.85 0.85,0.85ZM61.54,35c0.47,0 0.85,0.38 0.85,0.85v7.3h1.53a1.52,1.52 0,0 1,0 3.05h-1.53v-3.05h-1.7c-0.75,0 -1.36,-0.61 -1.36,-1.36v-4.07h-1.18a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.47 0.38,-0.85 0.85,-0.85h3.39ZM61.54,54.35h-3.39a0.85,0.85 0,0 1,-0.85 -0.85v-1.02c0,-0.46 0.38,-0.84 0.85,-0.84h1.18v-4.08c0,-0.75 0.61,-1.36 1.36,-1.36h1.7v7.3c0,0.47 -0.38,0.85 -0.85,0.85Z"
android:fillColor="@color/ic_launcher_foreground_tint"
android:fillType="evenOdd"/>
</vector>
</vector>

@ -0,0 +1,68 @@
/*
* 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.ui
import android.view.WindowInsets
import android.widget.FrameLayout
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children
/**
* A [DeviceConfigurationOverride] that allows overriding the [windowInsets] available
* to the content under test.
*/
@Suppress("ktlint:standard:function-naming")
fun DeviceConfigurationOverride.Companion.WindowInsets(
windowInsets: WindowInsetsCompat,
): DeviceConfigurationOverride = DeviceConfigurationOverride { contentUnderTest ->
val currentContentUnderTest by rememberUpdatedState(contentUnderTest)
val currentWindowInsets by rememberUpdatedState(windowInsets)
AndroidView(
factory = { context ->
object : FrameLayout(context) {
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
children.forEach {
it.dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets())
}
return WindowInsetsCompat.CONSUMED.toWindowInsets()!!
}
/**
* Deprecated, but intercept the `requestApplyInsets` call via the deprecated
* method.
*/
@Deprecated("Deprecated in Java")
override fun requestFitSystemWindows() {
dispatchApplyWindowInsets(currentWindowInsets.toWindowInsets()!!)
}
}.apply {
addView(
ComposeView(context).apply {
setContent {
currentContentUnderTest()
}
},
)
}
},
)
}

@ -16,27 +16,26 @@
package com.google.samples.apps.nowinandroid.ui
import android.util.Log
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.Posture
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.test.ForcedSize
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Configuration
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import androidx.window.core.layout.WindowSizeClass
import com.github.takahirom.roborazzi.captureRoboImage
import com.google.accompanist.testharness.TestHarness
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.UserNewsResourceRepository
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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
@ -60,7 +59,6 @@ import javax.inject.Inject
/**
* Tests that the navigation UI is rendered correctly on different screen sizes.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// Configure Robolectric to use a very large screen size that can fit all of the test sizes.
@ -93,6 +91,9 @@ class NiaAppScreenSizesScreenshotTests {
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
@Inject
lateinit var userDataRepository: UserDataRepository
@ -104,17 +105,6 @@ class NiaAppScreenSizesScreenshotTests {
@Before
fun setup() {
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setExecutor(SynchronousExecutor())
.build()
// Initialize WorkManager for instrumentation tests.
WorkManagerTestInitHelper.initializeTestWorkManager(
InstrumentationRegistry.getInstrumentation().context,
config,
)
hiltRule.inject()
// Configure user data
@ -133,19 +123,30 @@ class NiaAppScreenSizesScreenshotTests {
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun testNiaAppScreenshotWithSize(width: Dp, height: Dp, screenshotName: String) {
composeTestRule.setContent {
CompositionLocalProvider(
LocalInspectionMode provides true,
) {
TestHarness(size = DpSize(width, height)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
DeviceConfigurationOverride(
override = DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),
) {
NiaTheme {
val fakeAppState = rememberNiaAppState(
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
NiaApp(
fakeAppState,
windowAdaptiveInfo = WindowAdaptiveInfo(
windowSizeClass = WindowSizeClass.compute(
width.value,
height.value,
),
windowPosture = Posture(),
),
)
}
}
@ -169,20 +170,20 @@ class NiaAppScreenSizesScreenshotTests {
}
@Test
fun mediumWidth_compactHeight_showsNavigationRail() {
fun mediumWidth_compactHeight_showsNavigationBar() {
testNiaAppScreenshotWithSize(
610.dp,
400.dp,
"mediumWidth_compactHeight_showsNavigationRail",
"mediumWidth_compactHeight_showsNavigationBar",
)
}
@Test
fun expandedWidth_compactHeight_showsNavigationRail() {
fun expandedWidth_compactHeight_showsNavigationBar() {
testNiaAppScreenshotWithSize(
900.dp,
400.dp,
"expandedWidth_compactHeight_showsNavigationRail",
"expandedWidth_compactHeight_showsNavigationBar",
)
}

@ -0,0 +1,349 @@
/*
* Copyright 2023 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.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsEndWidth
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsStartWidth
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.Posture
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toAndroidRect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.test.ForcedSize
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpRect
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.roundToIntRect
import androidx.core.graphics.Insets
import androidx.core.view.WindowInsetsCompat
import androidx.window.core.layout.WindowSizeClass
import com.github.takahirom.roborazzi.captureRoboImage
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository
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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.LooperMode
import java.util.TimeZone
import javax.inject.Inject
/**
* Tests that the Snackbar is correctly displayed on different screen sizes.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// Configure Robolectric to use a very large screen size that can fit all of the test sizes.
// This allows enough room to render the content under test without clipping or scaling.
@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
@HiltAndroidTest
class SnackbarInsetsScreenshotTests {
/**
* Manages the components' state and is used to perform injection on your test
*/
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
/**
* Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash.
*/
@BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
@Inject
lateinit var userDataRepository: FakeUserDataRepository
@Inject
lateinit var topicsRepository: TopicsRepository
@Inject
lateinit var userNewsResourceRepository: UserNewsResourceRepository
@Before
fun setup() {
hiltRule.inject()
// Configure user data
runBlocking {
userDataRepository.setShouldHideOnboarding(true)
userDataRepository.setFollowedTopicIds(
setOf(topicsRepository.getTopics().first().first().id),
)
}
}
@Before
fun setTimeZone() {
// Make time zone deterministic in tests
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
@Test
fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"insets_snackbar_compact_medium_noSnackbar",
action = { },
)
}
@Test
fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"insets_snackbar_compact_medium",
) {
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
duration = Indefinite,
)
}
}
@Test
fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp,
600.dp,
"insets_snackbar_medium_medium",
) {
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
duration = Indefinite,
)
}
}
@Test
fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp,
900.dp,
"insets_snackbar_expanded_expanded",
) {
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
duration = Indefinite,
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp,
height: Dp,
screenshotName: String,
action: suspend () -> Unit,
) {
lateinit var scope: CoroutineScope
composeTestRule.setContent {
CompositionLocalProvider(
// Replaces images with placeholders
LocalInspectionMode provides true,
) {
scope = rememberCoroutineScope()
DeviceConfigurationOverride(
DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),
) {
DeviceConfigurationOverride(
DeviceConfigurationOverride.WindowInsets(
WindowInsetsCompat.Builder()
.setInsets(
WindowInsetsCompat.Type.statusBars(),
DpRect(
left = 0.dp,
top = 64.dp,
right = 0.dp,
bottom = 0.dp,
).toInsets(),
)
.setInsets(
WindowInsetsCompat.Type.navigationBars(),
DpRect(
left = 64.dp,
top = 0.dp,
right = 64.dp,
bottom = 64.dp,
).toInsets(),
)
.build(),
),
) {
BoxWithConstraints(Modifier.testTag("root")) {
NiaTheme {
val appState = rememberNiaAppState(
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},
windowAdaptiveInfo = WindowAdaptiveInfo(
windowSizeClass = WindowSizeClass.compute(
maxWidth.value,
maxHeight.value,
),
windowPosture = Posture(),
),
)
DebugVisibleWindowInsets()
}
}
}
}
}
}
scope.launch {
action()
}
composeTestRule.onNodeWithTag("root")
.captureRoboImage(
"src/testDemo/screenshots/$screenshotName.png",
roborazziOptions = DefaultRoborazziOptions,
)
}
}
@Composable
fun DebugVisibleWindowInsets(
modifier: Modifier = Modifier,
debugColor: Color = Color.Magenta.copy(alpha = 0.5f),
) {
Box(modifier = modifier.fillMaxSize()) {
Spacer(
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxHeight()
.windowInsetsStartWidth(WindowInsets.safeDrawing)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
.background(debugColor),
)
Spacer(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.windowInsetsEndWidth(WindowInsets.safeDrawing)
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical))
.background(debugColor),
)
Spacer(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.windowInsetsTopHeight(WindowInsets.safeDrawing)
.background(debugColor),
)
Spacer(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.windowInsetsBottomHeight(WindowInsets.safeDrawing)
.background(debugColor),
)
}
}
@Composable
private fun DpRect.toInsets() = toInsets(LocalDensity.current)
private fun DpRect.toInsets(density: Density) =
Insets.of(with(density) { toRect() }.roundToIntRect().toAndroidRect())

@ -0,0 +1,251 @@
/*
* Copyright 2023 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.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.Posture
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.test.ForcedSize
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import com.github.takahirom.roborazzi.captureRoboImage
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository
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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.LooperMode
import java.util.TimeZone
import javax.inject.Inject
/**
* Tests that the Snackbar is correctly displayed on different screen sizes.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
// Configure Robolectric to use a very large screen size that can fit all of the test sizes.
// This allows enough room to render the content under test without clipping or scaling.
@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi")
@LooperMode(LooperMode.Mode.PAUSED)
@HiltAndroidTest
class SnackbarScreenshotTests {
/**
* Manages the components' state and is used to perform injection on your test
*/
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
/**
* Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash.
*/
@BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
@Inject
lateinit var networkMonitor: NetworkMonitor
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
@Inject
lateinit var userDataRepository: FakeUserDataRepository
@Inject
lateinit var topicsRepository: TopicsRepository
@Inject
lateinit var userNewsResourceRepository: UserNewsResourceRepository
@Before
fun setup() {
hiltRule.inject()
// Configure user data
runBlocking {
userDataRepository.setShouldHideOnboarding(true)
userDataRepository.setFollowedTopicIds(
setOf(topicsRepository.getTopics().first().first().id),
)
}
}
@Before
fun setTimeZone() {
// Make time zone deterministic in tests
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
}
@Test
fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"snackbar_compact_medium_noSnackbar",
action = { },
)
}
@Test
fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"snackbar_compact_medium",
) {
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
duration = Indefinite,
)
}
}
@Test
fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp,
600.dp,
"snackbar_medium_medium",
) {
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
duration = Indefinite,
)
}
}
@Test
fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp,
900.dp,
"snackbar_expanded_expanded",
) {
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
duration = Indefinite,
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp,
height: Dp,
screenshotName: String,
action: suspend () -> Unit,
) {
lateinit var scope: CoroutineScope
composeTestRule.setContent {
CompositionLocalProvider(
// Replaces images with placeholders
LocalInspectionMode provides true,
) {
scope = rememberCoroutineScope()
DeviceConfigurationOverride(
DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),
) {
BoxWithConstraints {
NiaTheme {
val appState = rememberNiaAppState(
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},
windowAdaptiveInfo = WindowAdaptiveInfo(
windowSizeClass = WindowSizeClass.compute(
maxWidth.value,
maxHeight.value,
),
windowPosture = Posture(),
),
)
}
}
}
}
}
scope.launch {
action()
}
composeTestRule.onRoot()
.captureRoboImage(
"src/testDemo/screenshots/$screenshotName.png",
roborazziOptions = DefaultRoborazziOptions,
)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.NiaBuildType
import com.google.samples.apps.nowinandroid.configureFlavors
plugins {
@ -35,23 +34,6 @@ android {
buildConfig = true
}
buildTypes {
// This benchmark buildType is used for benchmarking, and should function like your
// release build (for example, with minification on). It's signed with a debug key
// for easy local/CI testing.
create("benchmark") {
// Keep the build type debuggable so we can attach a debugger if needed.
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks.add("release")
buildConfigField(
"String",
"APP_BUILD_TYPE_SUFFIX",
"\"${NiaBuildType.BENCHMARK.applicationIdSuffix ?: ""}\""
)
}
}
// Use the same flavor dimensions as the application to allow generating Baseline Profiles on prod,
// which is more close to what will be shipped to users (no fake data), but has ability to run the
// benchmarks on demo, so we benchmark on stable data.

@ -29,15 +29,11 @@ import androidx.test.uiautomator.HasChildrenOp.EXACTLY
fun untilHasChildren(
childCount: Int = 1,
op: HasChildrenOp = AT_LEAST,
): UiObject2Condition<Boolean> {
return object : UiObject2Condition<Boolean>() {
override fun apply(element: UiObject2): Boolean {
return when (op) {
AT_LEAST -> element.childCount >= childCount
EXACTLY -> element.childCount == childCount
AT_MOST -> element.childCount <= childCount
}
}
): UiObject2Condition<Boolean> = object : UiObject2Condition<Boolean>() {
override fun apply(element: UiObject2): Boolean = when (op) {
AT_LEAST -> element.childCount >= childCount
EXACTLY -> element.childCount == childCount
AT_MOST -> element.childCount <= childCount
}
}

@ -30,7 +30,6 @@ import java.io.ByteArrayOutputStream
val PACKAGE_NAME = buildString {
append("com.google.samples.apps.nowinandroid")
append(BuildConfig.APP_FLAVOR_SUFFIX)
append(BuildConfig.APP_BUILD_TYPE_SUFFIX)
}
fun UiDevice.flingElementDownUp(element: UiObject2) {

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.baselineprofile
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
@ -30,11 +31,9 @@ class StartupBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule()
@Test
fun generate() =
baselineProfileRule.collect(
PACKAGE_NAME,
includeInStartupProfile = true,
) {
startActivityAndAllowNotifications()
}
fun generate() = baselineProfileRule.collect(
PACKAGE_NAME,
includeInStartupProfile = true,
profileBlock = MacrobenchmarkScope::startActivityAndAllowNotifications,
)
}

@ -38,6 +38,9 @@ class ScrollForYouFeedBenchmark {
@Test
fun scrollFeedCompilationBaselineProfile() = scrollFeed(CompilationMode.Partial())
@Test
fun scrollFeedCompilationFull() = scrollFeed(CompilationMode.Full())
private fun scrollFeed(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(FrameTimingMetric()),

@ -14,6 +14,7 @@
* limitations under the License.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
@ -28,15 +29,17 @@ java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.android.tools.common)
compileOnly(libs.compose.gradlePlugin)
compileOnly(libs.firebase.crashlytics.gradlePlugin)
compileOnly(libs.firebase.performance.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin)

@ -18,12 +18,14 @@ import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
apply(plugin = "com.android.application")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)

@ -39,6 +39,8 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
@Suppress("UnstableApiUsage")
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}
extensions.configure<ApplicationAndroidComponentsExtension> {

@ -15,6 +15,7 @@
*/
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
import com.google.samples.apps.nowinandroid.configureJacoco
import org.gradle.api.Plugin
import org.gradle.api.Project
@ -23,13 +24,15 @@ import org.gradle.kotlin.dsl.getByType
class AndroidApplicationJacocoConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.gradle.jacoco")
apply("com.android.application")
pluginManager.apply("jacoco")
val androidExtension = extensions.getByType<BaseAppModuleExtension>()
androidExtension.buildTypes.configureEach {
enableAndroidTestCoverage = true
enableUnitTestCoverage = true
}
val extension = extensions.getByType<ApplicationAndroidComponentsExtension>()
configureJacoco(extension)
configureJacoco(extensions.getByType<ApplicationAndroidComponentsExtension>())
}
}
}
}

@ -34,6 +34,7 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}
@ -44,6 +45,9 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
}
}
}

@ -18,14 +18,14 @@ import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
import org.gradle.kotlin.dsl.kotlin
class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.library")
apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.compose")
val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(extension)

@ -21,6 +21,7 @@ import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask
import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
@ -39,6 +40,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 34
testOptions.animationsDisabled = true
configureFlavors(this)
configureGradleManagedDevices(this)
// The resource prefix is derived from the module name,
@ -51,6 +53,8 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
}
dependencies {
add("testImplementation", kotlin("test"))
add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
}
}
}

@ -14,6 +14,8 @@
* limitations under the License.
*/
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureJacoco
import org.gradle.api.Plugin
@ -23,13 +25,15 @@ import org.gradle.kotlin.dsl.getByType
class AndroidLibraryJacocoConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.gradle.jacoco")
apply("com.android.library")
pluginManager.apply("jacoco")
val androidExtension = extensions.getByType<LibraryExtension>()
androidExtension.buildTypes.configureEach {
enableAndroidTestCoverage = true
enableUnitTestCoverage = true
}
val extension = extensions.getByType<LibraryAndroidComponentsExtension>()
configureJacoco(extension)
configureJacoco(extensions.getByType<LibraryAndroidComponentsExtension>())
}
}
}
}

@ -15,6 +15,7 @@
*/
import androidx.room.gradle.RoomExtension
import com.google.devtools.ksp.gradle.KspExtension
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
@ -28,6 +29,10 @@ class AndroidRoomConventionPlugin : Plugin<Project> {
pluginManager.apply("androidx.room")
pluginManager.apply("com.google.devtools.ksp")
extensions.configure<KspExtension> {
arg("room.generateKotlin", "true")
}
extensions.configure<RoomExtension> {
// The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations.

@ -18,29 +18,29 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
/**
* Configure Compose-specific options
*/
internal fun Project.configureAndroidCompose(
commonExtension: CommonExtension<*, *, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString()
}
dependencies {
val bom = libs.findLibrary("androidx-compose-bom").get()
add("implementation", platform(bom))
add("androidTestImplementation", platform(bom))
add("implementation", libs.findLibrary("androidx-compose-ui-tooling-preview").get())
add("debugImplementation", libs.findLibrary("androidx-compose-ui-tooling").get())
}
testOptions {
@ -51,35 +51,22 @@ internal fun Project.configureAndroidCompose(
}
}
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters()
}
}
}
extensions.configure<ComposeCompilerGradlePluginExtension> {
fun Provider<String>.onlyIfTrue() = flatMap { provider { it.takeIf(String::toBoolean) } }
fun Provider<*>.relativeToRootProject(dir: String) = flatMap {
rootProject.layout.buildDirectory.dir(projectDir.toRelativeString(rootDir))
}.map { it.dir(dir) }
private fun Project.buildComposeMetricsParameters(): List<String> {
val metricParameters = mutableListOf<String>()
val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics")
val relativePath = projectDir.relativeTo(rootDir)
val buildDir = layout.buildDirectory.get().asFile
val enableMetrics = (enableMetricsProvider.orNull == "true")
if (enableMetrics) {
val metricsFolder = buildDir.resolve("compose-metrics").resolve(relativePath)
metricParameters.add("-P")
metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath
)
}
project.providers.gradleProperty("enableComposeCompilerMetrics").onlyIfTrue()
.relativeToRootProject("compose-metrics")
.let(metricsDestination::set)
project.providers.gradleProperty("enableComposeCompilerReports").onlyIfTrue()
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports")
val enableReports = (enableReportsProvider.orNull == "true")
if (enableReports) {
val reportsFolder = buildDir.resolve("compose-reports").resolve(relativePath)
metricParameters.add("-P")
metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath
)
stabilityConfigurationFile = rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
enableStrongSkippingMode = true
}
return metricParameters.toList()
}

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid
import com.android.SdkConstants
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.android.SdkConstants
import com.google.common.truth.Truth.assertWithMessage
import org.gradle.api.DefaultTask
import org.gradle.api.Project
@ -36,6 +36,7 @@ import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.register
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.process.ExecOperations
@ -117,23 +118,20 @@ fun Project.configureBadgingTasks(
val generateBadgingTaskName = "generate${capitalizedVariantName}Badging"
val generateBadging =
tasks.register<GenerateBadgingTask>(generateBadgingTaskName) {
apk.set(
variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE),
)
aapt2Executable.set(
File(
baseExtension.sdkDirectory,
"${SdkConstants.FD_BUILD_TOOLS}/" +
"${baseExtension.buildToolsVersion}/" +
SdkConstants.FN_AAPT2,
),
apk = variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE)
aapt2Executable = File(
baseExtension.sdkDirectory,
"${SdkConstants.FD_BUILD_TOOLS}/" +
"${baseExtension.buildToolsVersion}/" +
SdkConstants.FN_AAPT2,
)
badging.set(
project.layout.buildDirectory.file(
"outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt",
),
badging = project.layout.buildDirectory.file(
"outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt",
)
}
val updateBadgingTaskName = "update${capitalizedVariantName}Badging"
@ -144,17 +142,14 @@ fun Project.configureBadgingTasks(
val checkBadgingTaskName = "check${capitalizedVariantName}Badging"
tasks.register<CheckBadgingTask>(checkBadgingTaskName) {
goldenBadging.set(
project.layout.projectDirectory.file("${variant.name}-badging.txt"),
)
generatedBadging.set(
generateBadging.get().badging,
)
this.updateBadgingTaskName.set(updateBadgingTaskName)
goldenBadging = project.layout.projectDirectory.file("${variant.name}-badging.txt")
generatedBadging = generateBadging.get().badging
this.updateBadgingTaskName = updateBadgingTaskName
output = project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName")
output.set(
project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName"),
)
}
}
}

@ -25,7 +25,7 @@ import org.gradle.kotlin.dsl.invoke
* Configure project for Gradle managed devices
*/
internal fun configureGradleManagedDevices(
commonExtension: CommonExtension<*, *, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd")
val pixel6 = DeviceConfig("Pixel 6", 31, "aosp")

@ -1,5 +1,5 @@
/*
* Copyright 2022 The Android Open Source Project
* 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.
@ -16,9 +16,15 @@
package com.google.samples.apps.nowinandroid
import com.android.build.api.artifact.ScopedArtifact
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.api.variant.ScopedArtifacts
import org.gradle.api.Project
import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile
import org.gradle.api.provider.ListProperty
import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
@ -32,13 +38,24 @@ private val coverageExclusions = listOf(
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
"**/Manifest*.*"
"**/Manifest*.*",
"**/*_Hilt*.class",
"**/Hilt_*.class",
)
private fun String.capitalize() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
}
/**
* Creates a new task that generates a combined coverage report with data from local and
* instrumented tests.
*
* `create{variant}CombinedCoverageReport`
*
* Note that coverage data must exist before running the task. This allows us to run device
* tests on CI using a different Github Action or an external device farm.
*/
internal fun Project.configureJacoco(
androidComponentsExtension: AndroidComponentsExtension<*, *, *>,
) {
@ -46,37 +63,62 @@ internal fun Project.configureJacoco(
toolVersion = libs.findVersion("jacoco").get().toString()
}
val jacocoTestReport = tasks.create("jacocoTestReport")
androidComponentsExtension.onVariants { variant ->
val testTaskName = "test${variant.name.capitalize()}UnitTest"
val myObjFactory = project.objects
val buildDir = layout.buildDirectory.get().asFile
val reportTask = tasks.register("jacoco${testTaskName.capitalize()}Report", JacocoReport::class) {
dependsOn(testTaskName)
val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java)
val allDirectories: ListProperty<Directory> =
myObjFactory.listProperty(Directory::class.java)
val reportTask =
tasks.register(
"create${variant.name.capitalize()}CombinedCoverageReport",
JacocoReport::class,
) {
reports {
xml.required.set(true)
html.required.set(true)
}
classDirectories.setFrom(
fileTree("$buildDir/tmp/kotlin-classes/${variant.name}") {
exclude(coverageExclusions)
classDirectories.setFrom(
allJars,
allDirectories.map { dirs ->
dirs.map { dir ->
myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions)
}
},
)
reports {
xml.required = true
html.required = true
}
)
sourceDirectories.setFrom(files("$projectDir/src/main/java", "$projectDir/src/main/kotlin"))
executionData.setFrom(file("$buildDir/jacoco/$testTaskName.exec"))
}
// TODO: This is missing files in src/debug/, src/prod, src/demo, src/demoDebug...
sourceDirectories.setFrom(
files(
"$projectDir/src/main/java",
"$projectDir/src/main/kotlin",
),
)
executionData.setFrom(
project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest")
.matching { include("**/*.exec") },
project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest")
.matching { include("**/*.ec") },
)
}
jacocoTestReport.dependsOn(reportTask)
variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT)
.use(reportTask)
.toGet(
ScopedArtifact.CLASSES,
{ _ -> allJars },
{ _ -> allDirectories },
)
}
tasks.withType<Test>().configureEach {
configure<JacocoTaskExtension> {
// Required for JaCoCo + Robolectric
// https://github.com/robolectric/robolectric/issues/2230
// TODO: Consider removing if not we don't add Robolectric
isIncludeNoLocationClasses = true
// Required for JDK 11 with the above

@ -20,17 +20,20 @@ import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinTopLevelExtension
/**
* Configure base Kotlin with Android options
*/
internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = 34
@ -48,7 +51,7 @@ internal fun Project.configureKotlinAndroid(
}
}
configureKotlin()
configureKotlin<KotlinAndroidProjectExtension>()
dependencies {
add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get())
@ -66,26 +69,26 @@ internal fun Project.configureKotlinJvm() {
targetCompatibility = JavaVersion.VERSION_11
}
configureKotlin()
configureKotlin<KotlinJvmProjectExtension>()
}
/**
* Configure base Kotlin options
*/
private fun Project.configureKotlin() {
// Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions {
// Set JVM target to 11
jvmTarget = JavaVersion.VERSION_11.toString()
// Treat all Kotlin warnings as errors (disabled by default)
// Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
val warningsAsErrors: String? by project
allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs = freeCompilerArgs + listOf(
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
)
}
private inline fun <reified T : KotlinTopLevelExtension> Project.configureKotlin() = configure<T> {
// Treat all Kotlin warnings as errors (disabled by default)
// Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
val warningsAsErrors: String? by project
when (this) {
is KotlinAndroidProjectExtension -> compilerOptions
is KotlinJvmProjectExtension -> compilerOptions
else -> TODO("Unsupported project extension $this ${T::class}")
}.apply {
jvmTarget = JvmTarget.JVM_11
allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs.add(
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
)
}
}

@ -22,5 +22,4 @@ package com.google.samples.apps.nowinandroid
enum class NiaBuildType(val applicationIdSuffix: String? = null) {
DEBUG(".debug"),
RELEASE,
BENCHMARK(".benchmark")
}

@ -20,7 +20,7 @@ enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: St
}
fun configureFlavors(
commonExtension: CommonExtension<*, *, *, *, *>,
commonExtension: CommonExtension<*, *, *, *, *, *>,
flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {}
) {
commonExtension.apply {

@ -33,6 +33,7 @@ import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.assign
import org.gradle.work.DisableCachingByDefault
import java.io.File
@ -53,12 +54,12 @@ internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtensio
if (artifact != null && testSources != null) {
tasks.register(
"${variant.name}PrintTestApk",
PrintApkLocationTask::class.java
PrintApkLocationTask::class.java,
) {
apkFolder.set(artifact)
builtArtifactsLoader.set(loader)
variantName.set(variant.name)
sources.set(testSources)
apkFolder = artifact
builtArtifactsLoader = loader
variantName = variant.name
sources = testSources
}
}
}
@ -86,14 +87,12 @@ internal abstract class PrintApkLocationTask : DefaultTask() {
fun taskAction() {
val hasFiles = sources.orNull?.any { directory ->
directory.asFileTree.files.any {
it.isFile && it.parentFile.path.contains("build${File.separator}generated").not()
it.isFile && "build${File.separator}generated" !in it.parentFile.path
}
} ?: throw RuntimeException("Cannot check androidTest sources")
// Don't print APK location if there are no androidTest source files
if (!hasFiles) {
return
}
if (!hasFiles) return
val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get())
?: throw RuntimeException("Cannot load APKs")
@ -102,4 +101,4 @@ internal abstract class PrintApkLocationTask : DefaultTask() {
val apk = File(builtArtifacts.elements.single().outputFile).toPath()
println(apk)
}
}
}

@ -27,14 +27,16 @@ buildscript {
exclude(group = "com.google.protobuf")
}
}
}
// Lists all plugins used throughout the project without applying them.
// Lists all plugins used throughout the project
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.android.test) apply false
alias(libs.plugins.baselineprofile) apply false
alias(libs.plugins.compose) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.dependencyGuard) apply false
@ -46,4 +48,5 @@ plugins {
alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.secrets) apply false
alias(libs.plugins.room) apply false
alias(libs.plugins.module.graph) apply true // Plugin applied to allow module graph generation
}

@ -0,0 +1,11 @@
// This file contains classes (with possible wildcards) that the Compose Compiler will treat as stable.
// It allows us to define classes that our not part of our codebase without wrapping them in a stable class.
// For more information, check https://developer.android.com/jetpack/compose/performance/stability/fix#configuration-file
// We always use immutable classes for our data model, to avoid running the Compose compiler
// in the module we declare it to be stable here.
com.google.samples.apps.nowinandroid.core.model.data.*
// Java standard library classes
java.time.ZoneId
java.time.ZoneOffset

@ -0,0 +1,3 @@
# :core:analytics module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_analytics.svg)

@ -1,3 +1,3 @@
# :core:common module
![Dependency graph](../../docs/images/graphs/dep_graph_core_common.png)
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_common.svg)

@ -23,15 +23,10 @@ import kotlinx.coroutines.flow.onStart
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing>
data class Error(val exception: Throwable) : Result<Nothing>
data object Loading : Result<Nothing>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> {
return this
.map<T, Result<T>> {
Result.Success(it)
}
.onStart { emit(Result.Loading) }
.catch { emit(Result.Error(it)) }
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> = map<T, Result<T>> { Result.Success(it) }
.onStart { emit(Result.Loading) }
.catch { emit(Result.Error(it)) }

@ -38,7 +38,7 @@ class ResultKtTest {
when (val errorResult = awaitItem()) {
is Result.Error -> assertEquals(
"Test Done",
errorResult.exception?.message,
errorResult.exception.message,
)
Result.Loading,
is Result.Success,

@ -1,3 +1,3 @@
# :core:data-test module
![Dependency graph](../../docs/images/graphs/dep_graph_core_data_test.png)
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_data_test.svg)

@ -1,5 +1,5 @@
/*
* Copyright 2023 The Android Open Source Project
* 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.
@ -14,18 +14,14 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.domain
package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.datetime.TimeZone
import javax.inject.Inject
/**
* A use case which returns total count of *Fts tables
*/
class GetSearchContentsCountUseCase @Inject constructor(
private val searchContentsRepository: SearchContentsRepository,
) {
operator fun invoke(): Flow<Int> =
searchContentsRepository.getSearchContentsCount()
class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor {
override val currentTimeZone: Flow<TimeZone> = flowOf(TimeZone.of("Europe/Warsaw"))
}

@ -22,12 +22,13 @@ import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRep
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
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.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeNewsRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeRecentSearchRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeSearchContentsRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeTopicsRepository
import com.google.samples.apps.nowinandroid.core.data.test.repository.FakeUserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
@ -38,7 +39,7 @@ import dagger.hilt.testing.TestInstallIn
components = [SingletonComponent::class],
replaces = [DataModule::class],
)
interface TestDataModule {
internal interface TestDataModule {
@Binds
fun bindsTopicRepository(
fakeTopicsRepository: FakeTopicsRepository,
@ -68,4 +69,7 @@ interface TestDataModule {
fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor,
): NetworkMonitor
@Binds
fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor
}

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity
@ -25,7 +25,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
@ -41,7 +41,7 @@ import javax.inject.Inject
*/
class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource,
private val datasource: DemoNiaNetworkDataSource,
) : NewsRepository {
override fun getNewsResources(

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
@ -25,11 +25,11 @@ import javax.inject.Inject
/**
* Fake implementation of the [RecentSearchRepository]
*/
class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { /* no-op */ }
internal class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) = Unit
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
flowOf(emptyList())
override suspend fun clearRecentSearches() { /* no-op */ }
override suspend fun clearRecentSearches() = Unit
}

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
@ -25,9 +25,9 @@ import javax.inject.Inject
/**
* Fake implementation of the [SearchContentsRepository]
*/
class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
internal class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
override suspend fun populateFtsData() { /* no-op */ }
override suspend fun populateFtsData() = Unit
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()
override fun getSearchContentsCount(): Flow<Int> = flowOf(1)
}

@ -14,14 +14,14 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@ -36,9 +36,9 @@ import javax.inject.Inject
* This allows us to run the app with fake data, without needing an internet connection or working
* backend.
*/
class FakeTopicsRepository @Inject constructor(
internal class FakeTopicsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: FakeNiaNetworkDataSource,
private val datasource: DemoNiaNetworkDataSource,
) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> = flow {
emit(
@ -55,9 +55,8 @@ class FakeTopicsRepository @Inject constructor(
)
}.flowOn(ioDispatcher)
override fun getTopic(id: String): Flow<Topic> {
return getTopics().map { it.first { topic -> topic.id == id } }
}
override fun getTopic(id: String): Flow<Topic> = getTopics()
.map { it.first { topic -> topic.id == id } }
override suspend fun syncWith(synchronizer: Synchronizer) = true
}

@ -14,7 +14,7 @@
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.data.repository.fake
package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
@ -43,7 +43,7 @@ class FakeUserDataRepository @Inject constructor(
override suspend fun setTopicIdFollowed(followedTopicId: String, followed: Boolean) =
niaPreferencesDataSource.setTopicIdFollowed(followedTopicId, followed)
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {
niaPreferencesDataSource.setNewsResourceBookmarked(newsResourceId, bookmarked)
}

@ -1,3 +1,3 @@
# :core:data module
![Dependency graph](../../docs/images/graphs/dep_graph_core_data.png)
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_data.svg)

@ -19,8 +19,6 @@ package com.google.samples.apps.nowinandroid.core.data
import android.util.Log
import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlin.coroutines.cancellation.CancellationException
/**
@ -104,29 +102,3 @@ suspend fun Synchronizer.changeListSync(
versionUpdater(latestVersion)
}
}.isSuccess
/**
* Returns a [Flow] whose values are generated by [transform] function that process the most
* recently emitted values by each flow.
*/
fun <T1, T2, T3, T4, T5, T6, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(
combine(flow, flow2, flow3, ::Triple),
combine(flow4, flow5, flow6, ::Triple),
) { t1, t2 ->
transform(
t1.first,
t1.second,
t1.third,
t2.first,
t2.second,
t2.third,
)
}

@ -28,6 +28,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneBroadcastMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
@ -66,4 +68,7 @@ abstract class DataModule {
internal abstract fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor,
): NetworkMonitor
@Binds
internal abstract fun binds(impl: TimeZoneBroadcastMonitor): TimeZoneMonitor
}

@ -39,9 +39,7 @@ internal class DefaultRecentSearchRepository @Inject constructor(
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries ->
searchQueries.map {
it.asExternalModel()
}
searchQueries.map { it.asExternalModel() }
}
override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries()

@ -42,7 +42,7 @@ internal class OfflineFirstUserDataRepository @Inject constructor(
analyticsHelper.logTopicFollowToggled(followedTopicId, followed)
}
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
override suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {
niaPreferencesDataSource.setNewsResourceBookmarked(newsResourceId, bookmarked)
analyticsHelper.logNewsResourceBookmarkToggled(
newsResourceId = newsResourceId,

@ -41,7 +41,7 @@ interface UserDataRepository {
/**
* Updates the bookmarked status for a news resource
*/
suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean)
/**
* Updates the viewed status for a news resource

@ -26,57 +26,68 @@ import android.net.NetworkRequest.Builder
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
internal class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>()
if (connectivityManager == null) {
channel.trySend(false)
channel.close()
return@callbackFlow
}
trace("NetworkMonitor.callbackFlow") {
val connectivityManager = context.getSystemService<ConnectivityManager>()
if (connectivityManager == null) {
channel.trySend(false)
channel.close()
return@callbackFlow
}
/**
* The callback's methods are invoked on changes to *any* network matching the [NetworkRequest],
* not just the active network. So we can simply track the presence (or absence) of such [Network].
*/
val callback = object : NetworkCallback() {
/**
* The callback's methods are invoked on changes to *any* network matching the [NetworkRequest],
* not just the active network. So we can simply track the presence (or absence) of such [Network].
*/
val callback = object : NetworkCallback() {
private val networks = mutableSetOf<Network>()
private val networks = mutableSetOf<Network>()
override fun onAvailable(network: Network) {
networks += network
channel.trySend(true)
}
override fun onAvailable(network: Network) {
networks += network
channel.trySend(true)
}
override fun onLost(network: Network) {
networks -= network
channel.trySend(networks.isNotEmpty())
override fun onLost(network: Network) {
networks -= network
channel.trySend(networks.isNotEmpty())
}
}
}
val request = Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
trace("NetworkMonitor.registerNetworkCallback") {
val request = Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
}
/**
* Sends the latest connectivity status to the underlying channel.
*/
channel.trySend(connectivityManager.isCurrentlyConnected())
/**
* Sends the latest connectivity status to the underlying channel.
*/
channel.trySend(connectivityManager.isCurrentlyConnected())
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}
}
.flowOn(ioDispatcher)
.conflate()
@Suppress("DEPRECATION")

@ -0,0 +1,107 @@
/*
* 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.core.data.util
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.shareIn
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toKotlinTimeZone
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/**
* Utility for reporting current timezone the device has set.
* It always emits at least once with default setting and then for each TZ change.
*/
interface TimeZoneMonitor {
val currentTimeZone: Flow<TimeZone>
}
@Singleton
internal class TimeZoneBroadcastMonitor @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope appScope: CoroutineScope,
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
) : TimeZoneMonitor {
override val currentTimeZone: SharedFlow<TimeZone> =
callbackFlow {
// Send the default time zone first.
trySend(TimeZone.currentSystemDefault())
// Registers BroadcastReceiver for the TimeZone changes
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_TIMEZONE_CHANGED) return
val zoneIdFromIntent = if (VERSION.SDK_INT < VERSION_CODES.R) {
null
} else {
// Starting Android R we also get the new TimeZone.
intent.getStringExtra(Intent.EXTRA_TIMEZONE)?.let { timeZoneId ->
// We need to convert it from java.util.Timezone to java.time.ZoneId
val zoneId = ZoneId.of(timeZoneId, ZoneId.SHORT_IDS)
// Convert to kotlinx.datetime.TimeZone
zoneId.toKotlinTimeZone()
}
}
// If there isn't a zoneId in the intent, fallback to the systemDefault, which should also reflect the change
trySend(zoneIdFromIntent ?: TimeZone.currentSystemDefault())
}
}
trace("TimeZoneBroadcastReceiver.register") {
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED))
}
// Send here again, because registering the Broadcast Receiver can take up to several milliseconds.
// This way, we can reduce the likelihood that a TZ change wouldn't be caught with the Broadcast Receiver.
trySend(TimeZone.currentSystemDefault())
awaitClose {
context.unregisterReceiver(receiver)
}
}
// We use to prevent multiple emissions of the same type, because we use trySend multiple times.
.distinctUntilChanged()
.conflate()
.flowOn(ioDispatcher)
// Sharing the callback to prevent multiple BroadcastReceivers being registered
.shareIn(appScope, SharingStarted.WhileSubscribed(5_000), 1)
}

@ -82,7 +82,7 @@ class CompositeUserNewsResourceRepositoryTest {
// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.filter { sampleTopic1 in it.topics }
.mapToUserNewsResources(emptyUserData),
userNewsResources.first(),
)
@ -104,7 +104,7 @@ class CompositeUserNewsResourceRepositoryTest {
// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.filter { sampleTopic1 in it.topics }
.mapToUserNewsResources(userData),
userNewsResources.first(),
)

@ -91,14 +91,14 @@ class UserNewsResourceTest {
// Construct the expected FollowableTopic.
val followableTopic = FollowableTopic(
topic = topic,
isFollowed = userData.followedTopics.contains(topic.id),
isFollowed = topic.id in userData.followedTopics,
)
assertTrue(userNewsResource.followableTopics.contains(followableTopic))
}
// Check that the saved flag is set correctly.
assertEquals(
userData.bookmarkedNewsResources.contains(newsResource1.id),
newsResource1.id in userData.bookmarkedNewsResources,
userNewsResource.isSaved,
)
}

@ -133,7 +133,7 @@ class OfflineFirstUserDataRepositoryTest {
@Test
fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() =
testScope.runTest {
subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true)
subject.setNewsResourceBookmarked(newsResourceId = "0", bookmarked = true)
assertEquals(
setOf("0"),
@ -142,7 +142,7 @@ class OfflineFirstUserDataRepositoryTest {
.first(),
)
subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true)
subject.setNewsResourceBookmarked(newsResourceId = "1", bookmarked = true)
assertEquals(
setOf("0", "1"),

@ -34,9 +34,7 @@ val nonPresentInterestsIds = setOf("2")
*/
class TestNewsResourceDao : NewsResourceDao {
private var entitiesStateFlow = MutableStateFlow(
emptyList<NewsResourceEntity>(),
)
private val entitiesStateFlow = MutableStateFlow(emptyList<NewsResourceEntity>())
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
@ -94,21 +92,6 @@ class TestNewsResourceDao : NewsResourceDao {
result.map { it.entity.id }
}
override suspend fun insertOrIgnoreNewsResources(
entities: List<NewsResourceEntity>,
): List<Long> {
entitiesStateFlow.update { oldValues ->
// Old values come first so new values don't overwrite them
(oldValues + entities)
.distinctBy(NewsResourceEntity::id)
.sortedWith(
compareBy(NewsResourceEntity::publishDate).reversed(),
)
}
// Assume no conflicts on insert
return entities.map { it.id.toLong() }
}
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
entitiesStateFlow.update { oldValues ->
// New values come first so they overwrite old values
@ -131,7 +114,7 @@ class TestNewsResourceDao : NewsResourceDao {
override suspend fun deleteNewsResources(ids: List<String>) {
val idSet = ids.toSet()
entitiesStateFlow.update { entities ->
entities.filterNot { idSet.contains(it.id) }
entities.filterNot { it.id in idSet }
}
}
}

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.core.data.testdoubles
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource
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.NetworkTopic
@ -35,7 +35,7 @@ enum class CollectionType {
*/
class TestNiaNetworkDataSource : NiaNetworkDataSource {
private val source = FakeNiaNetworkDataSource(
private val source = DemoNiaNetworkDataSource(
UnconfinedTestDispatcher(),
Json { ignoreUnknownKeys = true },
)
@ -91,11 +91,10 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
}
}
fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> =
when (version) {
null -> this
else -> this.filter { it.changeListVersion > version }
}
fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> = when (version) {
null -> this
else -> filter { it.changeListVersion > version }
}
/**
* Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null
@ -105,7 +104,7 @@ private fun <T> List<T>.matchIds(
idGetter: (T) -> String,
) = when (ids) {
null -> this
else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } }
else -> ids.toSet().let { idSet -> filter { idGetter(it) in idSet } }
}
/**

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save