Merge branch 'main' into mlykotom/optimize-startup

Change-Id: If8e49d8c5e3873657aef3592c2202839f70e7be9

# Conflicts:
#	app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt
pull/1369/head
Tomáš Mlynarič 1 week ago
commit b2c71025b5

@ -5,3 +5,13 @@
ij_kotlin_allow_trailing_comma=true ij_kotlin_allow_trailing_comma=true
ij_kotlin_allow_trailing_comma_on_call_site=true ij_kotlin_allow_trailing_comma_on_call_site=true
ktlint_function_naming_ignore_when_annotated_with=Composable, Test ktlint_function_naming_ignore_when_annotated_with=Composable, Test
ktlint_standard_backing-property-naming = disabled
ktlint_standard_binary-expression-wrapping = disabled
ktlint_standard_chain-method-continuation = disabled
ktlint_standard_class-signature = disabled
ktlint_standard_condition-wrapping = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-literal = disabled
ktlint_standard_function-type-modifier-spacing = disabled
ktlint_standard_multiline-loop = disabled
ktlint_standard_function-signature = disabled

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

@ -1,26 +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** ## 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**
_Include a summary of what your pull request contains, and why you have made these changes._ - [Sign the contributors license agreement](https://cla.developers.google.com/)
Fixes #<issue_number_goes_here> **Ensure tests pass and code is formatted correctly**
**How I'm testing it** - Run local tests on the `DemoDebug` variant by running `./gradlew testDemoDebug`
- Fix code formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply`
_Choose at least one:_ **Add a description**
- Unit tests
- UI tests
- Screenshot tests
- N/A _(provide justification)_
**Do tests pass?** 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.
- [ ] Run local tests on `DemoDebug` variant: `./gradlew testDemoDebug`
- [ ] Check formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply`
**Is this your first pull request?** [Here's an example](https://github.com/android/nowinandroid/pull/1257).
- [ ] [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).
**NOW DELETE THIS LINE AND EVERYTHING ABOVE IT**
**What I have done and why**
\<add your PR description here\>

@ -25,8 +25,12 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Validate Gradle Wrapper - name: Enable KVM group perms
uses: gradle/wrapper-validation-action@v2 run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Copy CI gradle.properties - name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
@ -38,7 +42,13 @@ jobs:
java-version: 17 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v3 uses: gradle/actions/setup-gradle@v4
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Accept licenses
run: yes | sdkmanager --licenses || true
- name: Check build-logic - name: Check build-logic
run: ./gradlew check -p build-logic run: ./gradlew check -p build-logic
@ -83,7 +93,9 @@ jobs:
continue-on-error: false continue-on-error: false
if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: | run: |
echo "::error::Screenshot tests failed, please create a PR in your fork first." && exit 1 echo "::error::Screenshot tests failed, please create a PR in your fork first."
echo "Your fork's CI will take screenshots for your fork."
exit 1
# Runs if previous job failed # Runs if previous job failed
- name: Generate new screenshots if verification failed and it's a PR - name: Generate new screenshots if verification failed and it's a PR
@ -101,19 +113,22 @@ jobs:
commit_message: "🤖 Updates screenshots" commit_message: "🤖 Updates screenshots"
# Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots. # Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots.
- name: Run local tests and create report - name: Run local tests
if: always()
run: ./gradlew testDemoDebug :lint:test run: ./gradlew testDemoDebug :lint:test
# Replace task exclusions with `-Pandroidx.baselineprofile.skipgeneration` when
# https://android-review.googlesource.com/c/platform/frameworks/support/+/2602790 landed in a - name: Setup GMD
# release build run: ./gradlew :benchmarks:pixel6Api33Setup
--info
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Build all build type and flavor permutations - name: Build all build type and flavor permutations
run: ./gradlew :app:assemble :benchmarks:assemble run: ./gradlew :app:assemble :benchmarks:assemble -Pandroidx.baselineprofile.skipgeneration
-x pixel6Api33ProdNonMinifiedReleaseAndroidTest -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
-x pixel6Api33DemoNonMinifiedReleaseAndroidTest -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-x collectDemoNonMinifiedReleaseBaselineProfile -Pandroid.experimental.androidTest.numManagedDeviceShards=1
-x collectProdNonMinifiedReleaseBaselineProfile -Pandroid.experimental.testOptions.managedDevices.maxConcurrentDevices=1
-Pandroid.experimental.testOptions.managedDevices.setupTimeoutMinutes=5
- name: Upload build outputs (APKs) - name: Upload build outputs (APKs)
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -122,22 +137,35 @@ jobs:
path: '**/build/outputs/apk/**/*.apk' path: '**/build/outputs/apk/**/*.apk'
- name: Upload JVM local results (XML) - name: Upload JVM local results (XML)
if: always() if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: local-test-results name: local-test-results
path: '**/build/test-results/test*UnitTest/**.xml' path: '**/build/test-results/test*UnitTest/**.xml'
- name: Upload screenshot results (PNG)
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: screenshot-test-results
path: '**/build/outputs/roborazzi/*_compare.png'
- name: Check lint - name: Check lint
run: ./gradlew :app:lintProdRelease :app-nia-catalog:lintRelease :lint:lint run: ./gradlew :app:lintProdRelease :app-nia-catalog:lintRelease :lint:lint
- name: Upload lint reports (HTML) - name: Upload lint reports (HTML)
if: always() if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: lint-reports name: lint-reports
path: '**/build/reports/lint-results-*.html' path: '**/build/reports/lint-results-*.html'
- name: Upload lint reports (SARIF)
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: './'
- name: Check badging - name: Check badging
run: ./gradlew :app:checkProdReleaseBadging run: ./gradlew :app:checkProdReleaseBadging
@ -146,7 +174,7 @@ jobs:
timeout-minutes: 55 timeout-minutes: 55
strategy: strategy:
matrix: matrix:
api-level: [26, 30] api-level: [26, 34]
steps: steps:
- name: Delete unnecessary tools 🔧 - name: Delete unnecessary tools 🔧
@ -180,7 +208,10 @@ jobs:
java-version: 17 java-version: 17
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v3 uses: gradle/actions/setup-gradle@v4
with:
validate-wrappers: true
gradle-home-cache-cleanup: true
- name: Build projects and run instrumentation tests - name: Build projects and run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
@ -203,7 +234,7 @@ jobs:
run: ./gradlew createDemoDebugCombinedCoverageReport run: ./gradlew createDemoDebugCombinedCoverageReport
- name: Upload test reports - name: Upload test reports
if: always() if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: test-reports-${{ matrix.api-level }} name: test-reports-${{ matrix.api-level }}
@ -212,7 +243,7 @@ jobs:
- name: Display local test coverage (only API 30) - name: Display local test coverage (only API 30)
if: matrix.api-level == 30 if: matrix.api-level == 30
id: jacoco id: jacoco
uses: madrapps/jacoco-report@v1.6.1 uses: madrapps/jacoco-report@v1.7.1
with: with:
title: Combined test coverage report title: Combined test coverage report
min-coverage-overall: 40 min-coverage-overall: 40

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

@ -19,10 +19,8 @@ jobs:
ls /dev/kvm ls /dev/kvm
- name: Checkout - name: Checkout
uses: actions/checkout@v4
- name: Validate Gradle Wrapper uses: actions/checkout@v4
uses: gradle/wrapper-validation-action@v2
- name: Copy CI gradle.properties - name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
@ -33,11 +31,20 @@ jobs:
distribution: 'zulu' distribution: 'zulu'
java-version: 17 java-version: 17
- name: Install GMD image for baseline profile generation - name: Setup Gradle
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager "system-images;android-33;aosp_atd;x86_64" uses: gradle/actions/setup-gradle@v4
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Accept Android licenses - name: Accept licenses
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true run: yes | sdkmanager --licenses || true
- name: Setup GMD
run: ./gradlew :benchmarks:pixel6Api33Setup
--info
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
- name: Build release variant including baseline profile generation - name: Build release variant including baseline profile generation
run: ./gradlew :app:assembleDemoRelease run: ./gradlew :app:assembleDemoRelease

4
.gitignore vendored

@ -13,6 +13,7 @@ bin/
gen/ gen/
out/ out/
build/ build/
generated/
# Local configuration file (sdk path, etc) # Local configuration file (sdk path, etc)
local.properties local.properties
@ -43,3 +44,6 @@ _sandbox
# Android Studio captures folder # Android Studio captures folder
captures/ captures/
# Kotlin
.kotlin

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

@ -111,11 +111,13 @@ Examples:
To run the tests execute the following gradle tasks: To run the tests execute the following gradle tasks:
- `testDemoDebug` run all local tests against the `demoDebug` variant. - `testDemoDebug` run all local tests against the `demoDebug` variant. Screenshot tests will fail
(see below for explanation). To avoid this, run `recordRoborazziDemoDebug` prior to running unit tests.
- `connectedDemoDebugAndroidTest` run all instrumented tests against the `demoDebug` variant. - `connectedDemoDebugAndroidTest` run all instrumented tests against the `demoDebug` variant.
**Note:** You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute > [!NOTE]
tests against _all_ build variants which is both unecessary and will result in failures as only the > You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute
tests against _all_ build variants which is both unnecessary and will result in failures as only the
`demoDebug` variant is supported. No other variants have any tests (although this might change in future). `demoDebug` variant is supported. No other variants have any tests (although this might change in future).
## Screenshot tests ## Screenshot tests
@ -137,7 +139,9 @@ stored in `modulename/src/test/screenshots`.
- `compareRoborazziDemoDebug` create comparison images between failed tests and the known correct - `compareRoborazziDemoDebug` create comparison images between failed tests and the known correct
images. These can also be found in `modulename/src/test/screenshots`. images. These can also be found in `modulename/src/test/screenshots`.
**Note:** The known correct screenshots stored in this repository are recorded on CI using Linux. Other > [!NOTE]
> **Note on failing screenshot tests**
> The known correct screenshots stored in this repository are recorded on CI using Linux. Other
platforms may (and probably will) generate slightly different images, making the screenshot tests fail. platforms may (and probably will) generate slightly different images, making the screenshot tests fail.
When working on a non-Linux platform, a workaround to this is to run `recordRoborazziDemoDebug` on the When working on a non-Linux platform, a workaround to this is to run `recordRoborazziDemoDebug` on the
`main` branch before starting work. After making changes, `verifyRoborazziDemoDebug` will identify only `main` branch before starting work. After making changes, `verifyRoborazziDemoDebug` will identify only

@ -1,74 +1,85 @@
androidx.activity:activity-compose:1.8.0 androidx.activity:activity-compose:1.9.3
androidx.activity:activity-ktx:1.8.0 androidx.activity:activity-ktx:1.9.3
androidx.activity:activity:1.8.0 androidx.activity:activity:1.9.3
androidx.annotation:annotation-experimental:1.4.0 androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-jvm:1.7.1 androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.7.1 androidx.annotation:annotation:1.8.1
androidx.appcompat:appcompat-resources:1.6.1 androidx.appcompat:appcompat-resources:1.6.1
androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0 androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0 androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.8.0 androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0 androidx.collection:collection-jvm:1.4.4
androidx.collection:collection-ktx:1.4.0 androidx.collection:collection-ktx:1.4.4
androidx.collection:collection:1.4.0 androidx.collection:collection:1.4.4
androidx.compose.animation:animation-android:1.6.3 androidx.compose.animation:animation-android:1.7.5
androidx.compose.animation:animation-core-android:1.6.3 androidx.compose.animation:animation-core-android:1.7.5
androidx.compose.animation:animation-core:1.6.3 androidx.compose.animation:animation-core:1.7.5
androidx.compose.animation:animation:1.6.3 androidx.compose.animation:animation:1.7.5
androidx.compose.foundation:foundation-android:1.6.3 androidx.compose.foundation:foundation-android:1.7.5
androidx.compose.foundation:foundation-layout-android:1.6.3 androidx.compose.foundation:foundation-layout-android:1.7.5
androidx.compose.foundation:foundation-layout:1.6.3 androidx.compose.foundation:foundation-layout:1.7.5
androidx.compose.foundation:foundation:1.6.3 androidx.compose.foundation:foundation:1.7.5
androidx.compose.material3:material3-android:1.2.1 androidx.compose.material3.adaptive:adaptive-android:1.0.0
androidx.compose.material3:material3:1.2.1 androidx.compose.material3.adaptive:adaptive:1.0.0
androidx.compose.material:material-icons-core-android:1.6.3 androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.1
androidx.compose.material:material-icons-core:1.6.3 androidx.compose.material3:material3-adaptive-navigation-suite:1.3.1
androidx.compose.material:material-icons-extended-android:1.6.3 androidx.compose.material3:material3-android:1.3.1
androidx.compose.material:material-icons-extended:1.6.3 androidx.compose.material3:material3:1.3.1
androidx.compose.material:material-ripple-android:1.6.3 androidx.compose.material:material-icons-core-android:1.7.5
androidx.compose.material:material-ripple:1.6.3 androidx.compose.material:material-icons-core:1.7.5
androidx.compose.runtime:runtime-android:1.6.3 androidx.compose.material:material-icons-extended-android:1.7.5
androidx.compose.runtime:runtime-saveable-android:1.6.3 androidx.compose.material:material-icons-extended:1.7.5
androidx.compose.runtime:runtime-saveable:1.6.3 androidx.compose.material:material-ripple-android:1.7.5
androidx.compose.runtime:runtime:1.6.3 androidx.compose.material:material-ripple:1.7.5
androidx.compose.ui:ui-android:1.6.3 androidx.compose.runtime:runtime-android:1.7.5
androidx.compose.ui:ui-geometry-android:1.6.3 androidx.compose.runtime:runtime-saveable-android:1.7.5
androidx.compose.ui:ui-geometry:1.6.3 androidx.compose.runtime:runtime-saveable:1.7.5
androidx.compose.ui:ui-graphics-android:1.6.3 androidx.compose.runtime:runtime:1.7.5
androidx.compose.ui:ui-graphics:1.6.3 androidx.compose.ui:ui-android:1.7.5
androidx.compose.ui:ui-text-android:1.6.3 androidx.compose.ui:ui-geometry-android:1.7.5
androidx.compose.ui:ui-text:1.6.3 androidx.compose.ui:ui-geometry:1.7.5
androidx.compose.ui:ui-tooling-preview-android:1.6.3 androidx.compose.ui:ui-graphics-android:1.7.5
androidx.compose.ui:ui-tooling-preview:1.6.3 androidx.compose.ui:ui-graphics:1.7.5
androidx.compose.ui:ui-unit-android:1.6.3 androidx.compose.ui:ui-text-android:1.7.5
androidx.compose.ui:ui-unit:1.6.3 androidx.compose.ui:ui-text:1.7.5
androidx.compose.ui:ui-util-android:1.6.3 androidx.compose.ui:ui-tooling-preview-android:1.7.5
androidx.compose.ui:ui-util:1.6.3 androidx.compose.ui:ui-tooling-preview:1.7.5
androidx.compose.ui:ui:1.6.3 androidx.compose.ui:ui-unit-android:1.7.5
androidx.compose:compose-bom:2024.02.02 androidx.compose.ui:ui-unit:1.7.5
androidx.compose.ui:ui-util-android:1.7.5
androidx.compose.ui:ui-util:1.7.5
androidx.compose.ui:ui:1.7.5
androidx.compose:compose-bom:2024.11.00
androidx.concurrent:concurrent-futures:1.1.0 androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0 androidx.core:core-ktx:1.13.1
androidx.core:core:1.12.0 androidx.core:core:1.13.1
androidx.customview:customview-poolingcontainer:1.0.0 androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0 androidx.customview:customview:1.0.0
androidx.emoji2:emoji2:1.3.0 androidx.emoji2:emoji2:1.3.0
androidx.exifinterface:exifinterface:1.3.7 androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.1 androidx.fragment:fragment:1.5.1
androidx.graphics:graphics-path:1.0.1
androidx.interpolator:interpolator:1.0.0 androidx.interpolator:interpolator:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.7.0 androidx.lifecycle:lifecycle-common-java8:2.8.3
androidx.lifecycle:lifecycle-common:2.7.0 androidx.lifecycle:lifecycle-common-jvm:2.8.3
androidx.lifecycle:lifecycle-livedata-core-ktx:2.7.0 androidx.lifecycle:lifecycle-common:2.8.3
androidx.lifecycle:lifecycle-livedata-core:2.7.0 androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.3
androidx.lifecycle:lifecycle-livedata:2.7.0 androidx.lifecycle:lifecycle-livedata-core:2.8.3
androidx.lifecycle:lifecycle-process:2.7.0 androidx.lifecycle:lifecycle-livedata:2.8.3
androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 androidx.lifecycle:lifecycle-process:2.8.3
androidx.lifecycle:lifecycle-runtime:2.7.0 androidx.lifecycle:lifecycle-runtime-android:2.8.3
androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 androidx.lifecycle:lifecycle-runtime-compose-android:2.8.3
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0 androidx.lifecycle:lifecycle-runtime-compose:2.8.3
androidx.lifecycle:lifecycle-viewmodel:2.7.0 androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.3
androidx.lifecycle:lifecycle-runtime-ktx:2.8.3
androidx.lifecycle:lifecycle-runtime:2.8.3
androidx.lifecycle:lifecycle-viewmodel-android:2.8.3
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.3
androidx.lifecycle:lifecycle-viewmodel:2.8.3
androidx.loader:loader:1.0.0 androidx.loader:loader:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04 androidx.metrics:metrics-performance:1.0.0-beta01
androidx.profileinstaller:profileinstaller:1.3.1 androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1 androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1 androidx.savedstate:savedstate:1.2.1
@ -79,29 +90,34 @@ androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0 androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1 androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0 androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0
androidx.window:window-core:1.3.0
androidx.window:window:1.3.0
com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.code.findbugs:jsr305:3.0.2 com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.51 com.google.dagger:dagger-lint-aar:2.52
com.google.dagger:dagger:2.51 com.google.dagger:dagger:2.52
com.google.dagger:hilt-android:2.51 com.google.dagger:hilt-android:2.52
com.google.dagger:hilt-core:2.51 com.google.dagger:hilt-core:2.52
com.google.guava:listenablefuture:1.0 com.google.guava:listenablefuture:1.0
com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.8.0 com.squareup.okio:okio-jvm:3.9.0
com.squareup.okio:okio:3.8.0 com.squareup.okio:okio:3.9.0
io.coil-kt:coil-base:2.6.0 io.coil-kt:coil-base:2.7.0
io.coil-kt:coil-compose-base:2.6.0 io.coil-kt:coil-compose-base:2.7.0
io.coil-kt:coil-compose:2.6.0 io.coil-kt:coil-compose:2.7.0
io.coil-kt:coil:2.6.0 io.coil-kt:coil:2.7.0
jakarta.inject:jakarta.inject-api:2.0.1
javax.inject:javax.inject:1 javax.inject:javax.inject:1
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22 org.jetbrains.kotlin:kotlin-stdlib-common:2.0.20
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:1.9.22 org.jetbrains.kotlin:kotlin-stdlib:2.0.20
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.5.0 org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1
org.jetbrains.kotlinx:kotlinx-datetime:0.5.0 org.jetbrains.kotlinx:kotlinx-datetime:0.6.1
org.jetbrains:annotations:23.0.0 org.jetbrains:annotations:23.0.0

@ -20,11 +20,12 @@ plugins {
alias(libs.plugins.nowinandroid.android.application.compose) alias(libs.plugins.nowinandroid.android.application.compose)
alias(libs.plugins.nowinandroid.android.application.flavors) alias(libs.plugins.nowinandroid.android.application.flavors)
alias(libs.plugins.nowinandroid.android.application.jacoco) alias(libs.plugins.nowinandroid.android.application.jacoco)
alias(libs.plugins.nowinandroid.android.hilt)
alias(libs.plugins.nowinandroid.android.application.firebase) alias(libs.plugins.nowinandroid.android.application.firebase)
alias(libs.plugins.nowinandroid.hilt)
id("com.google.android.gms.oss-licenses-plugin") id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.baselineprofile) alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi) alias(libs.plugins.roborazzi)
alias(libs.plugins.kotlin.serialization)
} }
android { android {
@ -47,7 +48,7 @@ android {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
// To publish on the Play store a private signing key is required, but to allow anyone // To publish on the Play store a private signing key is required, but to allow anyone
// who clones the code to sign and run the release variant, use the debug signing key. // who clones the code to sign and run the release variant, use the debug signing key.
@ -100,8 +101,10 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.window.core)
implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt) implementation(libs.coil.kt)
implementation(libs.kotlinx.serialization.json)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
@ -111,10 +114,10 @@ dependencies {
kspTest(libs.hilt.compiler) kspTest(libs.hilt.compiler)
testImplementation(projects.core.dataTest) testImplementation(projects.core.dataTest)
testImplementation(projects.core.testing) testImplementation(projects.core.datastoreTest)
testImplementation(libs.accompanist.testharness)
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.work.testing) testImplementation(projects.sync.syncTest)
testImplementation(libs.kotlin.test)
testDemoImplementation(libs.robolectric) testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi) testDemoImplementation(libs.roborazzi)
@ -125,8 +128,9 @@ dependencies {
androidTestImplementation(projects.core.datastoreTest) androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(libs.androidx.test.espresso.core) androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.navigation.testing) androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness) androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.hilt.android.testing) androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.kotlin.test)
baselineProfile(projects.benchmarks) baselineProfile(projects.benchmarks)
} }
@ -135,6 +139,9 @@ baselineProfile {
// Don't build on every iteration of a full assemble. // Don't build on every iteration of a full assemble.
// Instead enable generation directly for the release build variant. // Instead enable generation directly for the release build variant.
automaticGenerationDuringBuild = false automaticGenerationDuringBuild = false
// Make use of Dex Layout Optimizations via Startup Profiles
dexLayoutOptimization = true
} }
dependencyGuard { dependencyGuard {

@ -1,111 +1,126 @@
androidx.activity:activity-compose:1.8.0 androidx.activity:activity-compose:1.9.3
androidx.activity:activity-ktx:1.8.0 androidx.activity:activity-ktx:1.9.3
androidx.activity:activity:1.8.0 androidx.activity:activity:1.9.3
androidx.annotation:annotation-experimental:1.4.0 androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-jvm:1.7.1 androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.7.1 androidx.annotation:annotation:1.8.1
androidx.appcompat:appcompat-resources:1.6.1 androidx.appcompat:appcompat-resources:1.7.0
androidx.appcompat:appcompat:1.6.1 androidx.appcompat:appcompat:1.7.0
androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0 androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0 androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.8.0 androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.0 androidx.collection:collection-jvm:1.4.4
androidx.collection:collection-ktx:1.4.0 androidx.collection:collection-ktx:1.4.4
androidx.collection:collection:1.4.0 androidx.collection:collection:1.4.4
androidx.compose.animation:animation-android:1.6.3 androidx.compose.animation:animation-android:1.7.5
androidx.compose.animation:animation-core-android:1.6.3 androidx.compose.animation:animation-core-android:1.7.5
androidx.compose.animation:animation-core:1.6.3 androidx.compose.animation:animation-core:1.7.5
androidx.compose.animation:animation:1.6.3 androidx.compose.animation:animation:1.7.5
androidx.compose.foundation:foundation-android:1.6.3 androidx.compose.foundation:foundation-android:1.7.5
androidx.compose.foundation:foundation-layout-android:1.6.3 androidx.compose.foundation:foundation-layout-android:1.7.5
androidx.compose.foundation:foundation-layout:1.6.3 androidx.compose.foundation:foundation-layout:1.7.5
androidx.compose.foundation:foundation:1.6.3 androidx.compose.foundation:foundation:1.7.5
androidx.compose.material3.adaptive:adaptive-android:1.0.0-alpha08 androidx.compose.material3.adaptive:adaptive-android:1.0.0
androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0-alpha08 androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0
androidx.compose.material3.adaptive:adaptive-layout:1.0.0-alpha08 androidx.compose.material3.adaptive:adaptive-layout:1.0.0
androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0-alpha08 androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0
androidx.compose.material3.adaptive:adaptive-navigation:1.0.0-alpha08 androidx.compose.material3.adaptive:adaptive-navigation:1.0.0
androidx.compose.material3.adaptive:adaptive:1.0.0-alpha08 androidx.compose.material3.adaptive:adaptive:1.0.0
androidx.compose.material3:material3-android:1.2.1 androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.1
androidx.compose.material3:material3-window-size-class-android:1.2.1 androidx.compose.material3:material3-adaptive-navigation-suite:1.3.1
androidx.compose.material3:material3-window-size-class:1.2.1 androidx.compose.material3:material3-android:1.3.1
androidx.compose.material3:material3:1.2.1 androidx.compose.material3:material3-window-size-class-android:1.3.1
androidx.compose.material:material-icons-core-android:1.6.3 androidx.compose.material3:material3-window-size-class:1.3.1
androidx.compose.material:material-icons-core:1.6.3 androidx.compose.material3:material3:1.3.1
androidx.compose.material:material-icons-extended-android:1.6.3 androidx.compose.material:material-icons-core-android:1.7.5
androidx.compose.material:material-icons-extended:1.6.3 androidx.compose.material:material-icons-core:1.7.5
androidx.compose.material:material-ripple-android:1.6.3 androidx.compose.material:material-icons-extended-android:1.7.5
androidx.compose.material:material-ripple:1.6.3 androidx.compose.material:material-icons-extended:1.7.5
androidx.compose.runtime:runtime-android:1.6.3 androidx.compose.material:material-ripple-android:1.7.5
androidx.compose.runtime:runtime-saveable-android:1.6.3 androidx.compose.material:material-ripple:1.7.5
androidx.compose.runtime:runtime-saveable:1.6.3 androidx.compose.runtime:runtime-android:1.7.5
androidx.compose.runtime:runtime-tracing:1.0.0-beta01 androidx.compose.runtime:runtime-saveable-android:1.7.5
androidx.compose.runtime:runtime:1.6.3 androidx.compose.runtime:runtime-saveable:1.7.5
androidx.compose.ui:ui-android:1.6.3 androidx.compose.runtime:runtime-tracing:1.7.5
androidx.compose.ui:ui-geometry-android:1.6.3 androidx.compose.runtime:runtime:1.7.5
androidx.compose.ui:ui-geometry:1.6.3 androidx.compose.ui:ui-android:1.7.5
androidx.compose.ui:ui-graphics-android:1.6.3 androidx.compose.ui:ui-geometry-android:1.7.5
androidx.compose.ui:ui-graphics:1.6.3 androidx.compose.ui:ui-geometry:1.7.5
androidx.compose.ui:ui-text-android:1.6.3 androidx.compose.ui:ui-graphics-android:1.7.5
androidx.compose.ui:ui-text:1.6.3 androidx.compose.ui:ui-graphics:1.7.5
androidx.compose.ui:ui-tooling-preview-android:1.6.3 androidx.compose.ui:ui-text-android:1.7.5
androidx.compose.ui:ui-tooling-preview:1.6.3 androidx.compose.ui:ui-text:1.7.5
androidx.compose.ui:ui-unit-android:1.6.3 androidx.compose.ui:ui-tooling-preview-android:1.7.5
androidx.compose.ui:ui-unit:1.6.3 androidx.compose.ui:ui-tooling-preview:1.7.5
androidx.compose.ui:ui-util-android:1.6.3 androidx.compose.ui:ui-unit-android:1.7.5
androidx.compose.ui:ui-util:1.6.3 androidx.compose.ui:ui-unit:1.7.5
androidx.compose.ui:ui:1.6.3 androidx.compose.ui:ui-util-android:1.7.5
androidx.compose:compose-bom:2024.02.02 androidx.compose.ui:ui-util:1.7.5
androidx.compose.ui:ui:1.7.5
androidx.compose:compose-bom:2024.11.00
androidx.concurrent:concurrent-futures:1.1.0 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-splashscreen:1.0.1
androidx.core:core:1.12.0 androidx.core:core:1.13.1
androidx.cursoradapter:cursoradapter:1.0.0 androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0 androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0 androidx.customview:customview:1.0.0
androidx.datastore:datastore-core:1.0.0 androidx.datastore:datastore-android:1.1.1
androidx.datastore:datastore-preferences-core:1.0.0 androidx.datastore:datastore-core-android:1.1.1
androidx.datastore:datastore-preferences:1.0.0 androidx.datastore:datastore-core-okio-jvm:1.1.1
androidx.datastore:datastore:1.0.0 androidx.datastore:datastore-core-okio:1.1.1
androidx.datastore:datastore-core:1.1.1
androidx.datastore:datastore-preferences-android:1.1.1
androidx.datastore:datastore-preferences-core-jvm:1.1.1
androidx.datastore:datastore-preferences-core:1.1.1
androidx.datastore:datastore-preferences:1.1.1
androidx.datastore:datastore:1.1.1
androidx.documentfile:documentfile:1.0.0 androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.0.0 androidx.drawerlayout:drawerlayout:1.0.0
androidx.emoji2:emoji2-views-helper:1.3.0 androidx.emoji2:emoji2-views-helper:1.3.0
androidx.emoji2:emoji2:1.3.0 androidx.emoji2:emoji2:1.3.0
androidx.exifinterface:exifinterface:1.3.7 androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.1 androidx.fragment:fragment:1.5.4
androidx.hilt:hilt-common:1.1.0 androidx.graphics:graphics-path:1.0.1
androidx.hilt:hilt-common:1.2.0
androidx.hilt:hilt-navigation-compose:1.2.0 androidx.hilt:hilt-navigation-compose:1.2.0
androidx.hilt:hilt-navigation:1.2.0 androidx.hilt:hilt-navigation:1.2.0
androidx.hilt:hilt-work:1.1.0 androidx.hilt:hilt-work:1.2.0
androidx.interpolator:interpolator:1.0.0 androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0 androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.7.0 androidx.lifecycle:lifecycle-common-java8:2.8.7
androidx.lifecycle:lifecycle-common:2.7.0 androidx.lifecycle:lifecycle-common-jvm:2.8.7
androidx.lifecycle:lifecycle-livedata-core-ktx:2.7.0 androidx.lifecycle:lifecycle-common:2.8.7
androidx.lifecycle:lifecycle-livedata-core:2.7.0 androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7
androidx.lifecycle:lifecycle-livedata:2.7.0 androidx.lifecycle:lifecycle-livedata-core:2.8.7
androidx.lifecycle:lifecycle-process:2.7.0 androidx.lifecycle:lifecycle-livedata:2.8.7
androidx.lifecycle:lifecycle-runtime-compose:2.7.0 androidx.lifecycle:lifecycle-process:2.8.7
androidx.lifecycle:lifecycle-runtime-ktx:2.7.0 androidx.lifecycle:lifecycle-runtime-android:2.8.7
androidx.lifecycle:lifecycle-runtime:2.7.0 androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7
androidx.lifecycle:lifecycle-service:2.7.0 androidx.lifecycle:lifecycle-runtime-compose:2.8.7
androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0 androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7
androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0 androidx.lifecycle:lifecycle-runtime-ktx:2.8.7
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.7.0 androidx.lifecycle:lifecycle-runtime:2.8.7
androidx.lifecycle:lifecycle-viewmodel:2.7.0 androidx.lifecycle:lifecycle-service:2.8.7
androidx.lifecycle:lifecycle-viewmodel-android:2.8.7
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.8.7
androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7
androidx.lifecycle:lifecycle-viewmodel:2.8.7
androidx.loader:loader:1.0.0 androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0 androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04 androidx.metrics:metrics-performance:1.0.0-beta01
androidx.navigation:navigation-common-ktx:2.7.4 androidx.navigation:navigation-common-ktx:2.8.4
androidx.navigation:navigation-common:2.7.4 androidx.navigation:navigation-common:2.8.4
androidx.navigation:navigation-compose:2.7.4 androidx.navigation:navigation-compose:2.8.4
androidx.navigation:navigation-runtime-ktx:2.7.4 androidx.navigation:navigation-runtime-ktx:2.8.4
androidx.navigation:navigation-runtime:2.7.4 androidx.navigation:navigation-runtime:2.8.4
androidx.print:print:1.0.0 androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05 androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05 androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
androidx.profileinstaller:profileinstaller:1.3.1 androidx.profileinstaller:profileinstaller:1.4.1
androidx.resourceinspection:resourceinspection-annotation:1.0.1 androidx.resourceinspection:resourceinspection-annotation:1.0.1
androidx.room:room-common:2.6.1 androidx.room:room-common:2.6.1
androidx.room:room-ktx:2.6.1 androidx.room:room-ktx:2.6.1
@ -123,92 +138,91 @@ androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1 androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0 androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0 androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0-alpha02 androidx.window:window-core-android:1.3.0
androidx.window:window-core:1.3.0-alpha02 androidx.window:window-core:1.3.0
androidx.window:window:1.3.0-alpha02 androidx.window:window:1.3.0
androidx.work:work-runtime-ktx:2.9.0 androidx.work:work-runtime-ktx:2.9.1
androidx.work:work-runtime:2.9.0 androidx.work:work-runtime:2.9.1
com.caverock:androidsvg-aar:1.4 com.caverock:androidsvg-aar:1.4
com.google.accompanist:accompanist-drawablepainter:0.32.0 com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.accompanist:accompanist-permissions:0.34.0 com.google.accompanist:accompanist-permissions:0.36.0
com.google.android.datatransport:transport-api:3.0.0 com.google.android.datatransport:transport-api:3.2.0
com.google.android.datatransport:transport-backend-cct:3.1.9 com.google.android.datatransport:transport-backend-cct:3.3.0
com.google.android.datatransport:transport-runtime:3.1.9 com.google.android.datatransport:transport-runtime:3.3.0
com.google.android.gms:play-services-ads-identifier:18.0.0 com.google.android.gms:play-services-ads-identifier:18.0.0
com.google.android.gms:play-services-base:18.0.1 com.google.android.gms:play-services-base:18.5.0
com.google.android.gms:play-services-basement:18.1.0 com.google.android.gms:play-services-basement:18.4.0
com.google.android.gms:play-services-cloud-messaging:17.0.1 com.google.android.gms:play-services-cloud-messaging:17.2.0
com.google.android.gms:play-services-measurement-api:21.4.0 com.google.android.gms:play-services-measurement-api:22.1.2
com.google.android.gms:play-services-measurement-base:21.4.0 com.google.android.gms:play-services-measurement-base:22.1.2
com.google.android.gms:play-services-measurement-impl:21.4.0 com.google.android.gms:play-services-measurement-impl:22.1.2
com.google.android.gms:play-services-measurement-sdk-api:21.4.0 com.google.android.gms:play-services-measurement-sdk-api:22.1.2
com.google.android.gms:play-services-measurement-sdk:21.4.0 com.google.android.gms:play-services-measurement-sdk:22.1.2
com.google.android.gms:play-services-measurement:21.4.0 com.google.android.gms:play-services-measurement:22.1.2
com.google.android.gms:play-services-oss-licenses:17.0.1 com.google.android.gms:play-services-oss-licenses:17.1.0
com.google.android.gms:play-services-stats:17.0.2 com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.0.2 com.google.android.gms:play-services-tasks:18.2.0
com.google.code.findbugs:jsr305:3.0.2 com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.51 com.google.dagger:dagger-lint-aar:2.52
com.google.dagger:dagger:2.51 com.google.dagger:dagger:2.52
com.google.dagger:hilt-android:2.51 com.google.dagger:hilt-android:2.52
com.google.dagger:hilt-core:2.51 com.google.dagger:hilt-core:2.52
com.google.errorprone:error_prone_annotations:2.11.0 com.google.errorprone:error_prone_annotations:2.26.0
com.google.firebase:firebase-abt:21.1.1 com.google.firebase:firebase-abt:21.1.1
com.google.firebase:firebase-analytics-ktx:21.4.0 com.google.firebase:firebase-analytics:22.1.2
com.google.firebase:firebase-analytics:21.4.0
com.google.firebase:firebase-annotations:16.2.0 com.google.firebase:firebase-annotations:16.2.0
com.google.firebase:firebase-bom:32.4.0 com.google.firebase:firebase-bom:33.7.0
com.google.firebase:firebase-common-ktx:20.4.2 com.google.firebase:firebase-common-ktx:21.0.0
com.google.firebase:firebase-common:20.4.2 com.google.firebase:firebase-common:21.0.0
com.google.firebase:firebase-components:17.1.5 com.google.firebase:firebase-components:18.0.0
com.google.firebase:firebase-config:21.5.0 com.google.firebase:firebase-config-interop:16.0.1
com.google.firebase:firebase-crashlytics-ktx:18.5.0 com.google.firebase:firebase-config:22.0.1
com.google.firebase:firebase-crashlytics:18.5.0 com.google.firebase:firebase-crashlytics:19.3.0
com.google.firebase:firebase-datatransport:18.1.8 com.google.firebase:firebase-datatransport:19.0.0
com.google.firebase:firebase-encoders-json:18.0.1 com.google.firebase:firebase-encoders-json:18.0.1
com.google.firebase:firebase-encoders-proto:16.0.0 com.google.firebase:firebase-encoders-proto:16.0.0
com.google.firebase:firebase-encoders:17.0.0 com.google.firebase:firebase-encoders:17.0.0
com.google.firebase:firebase-iid-interop:17.1.0 com.google.firebase:firebase-iid-interop:17.1.0
com.google.firebase:firebase-installations-interop:17.1.1 com.google.firebase:firebase-installations-interop:17.2.0
com.google.firebase:firebase-installations:17.2.0 com.google.firebase:firebase-installations:18.0.0
com.google.firebase:firebase-measurement-connector:19.0.0 com.google.firebase:firebase-measurement-connector:20.0.1
com.google.firebase:firebase-messaging-ktx:23.3.0 com.google.firebase:firebase-messaging:24.1.0
com.google.firebase:firebase-messaging:23.3.0 com.google.firebase:firebase-perf:21.0.3
com.google.firebase:firebase-perf-ktx:20.5.0 com.google.firebase:firebase-sessions:2.0.7
com.google.firebase:firebase-perf:20.5.0
com.google.firebase:firebase-sessions:1.1.0
com.google.firebase:protolite-well-known-types:18.0.0
com.google.guava:failureaccess:1.0.1 com.google.guava:failureaccess:1.0.1
com.google.guava:guava:31.1-android com.google.guava:guava:31.1-android
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:1.3 com.google.j2objc:j2objc-annotations:1.3
com.google.protobuf:protobuf-javalite:3.25.2 com.google.protobuf:protobuf-javalite:4.28.2
com.google.protobuf:protobuf-kotlin-lite:3.25.2 com.google.protobuf:protobuf-kotlin-lite:4.28.2
com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0
com.squareup.okhttp3:logging-interceptor:4.12.0 com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0 com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.8.0 com.squareup.okio:okio-jvm:3.9.0
com.squareup.okio:okio:3.8.0 com.squareup.okio:okio:3.9.0
com.squareup.retrofit2:retrofit:2.9.0 com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0
io.coil-kt:coil-base:2.6.0 com.squareup.retrofit2:retrofit:2.11.0
io.coil-kt:coil-compose-base:2.6.0 io.coil-kt:coil-base:2.7.0
io.coil-kt:coil-compose:2.6.0 io.coil-kt:coil-compose-base:2.7.0
io.coil-kt:coil-svg:2.6.0 io.coil-kt:coil-compose:2.7.0
io.coil-kt:coil:2.6.0 io.coil-kt:coil-svg:2.7.0
io.coil-kt:coil:2.7.0
jakarta.inject:jakarta.inject-api:2.0.1
javax.inject:javax.inject:1 javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0 org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.22 org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.22
org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22
org.jetbrains.kotlin:kotlin-stdlib-common:2.0.20
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0 org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0 org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:1.9.22 org.jetbrains.kotlin:kotlin-stdlib:2.0.20
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.8.0 org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.5.0 org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1
org.jetbrains.kotlinx:kotlinx-datetime:0.5.0 org.jetbrains.kotlinx:kotlinx-datetime:0.6.1
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.3
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3
org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3 org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.3

@ -119,3 +119,4 @@ supports-screens: 'small' 'normal' 'large' 'xlarge'
supports-any-density: 'true' 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' 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' densities: '120' '160' '240' '320' '480' '640' '65534'
native-code: 'arm64-v8a' 'armeabi-v7a' 'x86' 'x86_64'

@ -1,19 +0,0 @@
-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
-keep,allowobfuscation,allowshrinking class retrofit2.Response
# With R8 full mode generic signatures are stripped for classes that are not
# kept. Suspend functions are wrapped in continuations where the type argument
# is used.
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation

@ -16,16 +16,16 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.annotation.StringRes import androidx.compose.ui.semantics.SemanticsActions.ScrollBy
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@ -35,10 +35,10 @@ import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException import androidx.test.espresso.NoActivityResumedException
import com.google.samples.apps.nowinandroid.MainActivity import com.google.samples.apps.nowinandroid.MainActivity
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -46,9 +46,7 @@ import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import javax.inject.Inject import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.search.R as FeatureSearchR import com.google.samples.apps.nowinandroid.feature.search.R as FeatureSearchR
@ -66,31 +64,23 @@ class NavigationTest {
@get:Rule(order = 0) @get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this) 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. * Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
*/ */
@get:Rule(order = 2) @get:Rule(order = 1)
val postNotificationsPermission = GrantPostNotificationsPermissionRule() val postNotificationsPermission = GrantPostNotificationsPermissionRule()
/** /**
* Use the primary activity to initialize the app normally. * Use the primary activity to initialize the app normally.
*/ */
@get:Rule(order = 3) @get:Rule(order = 2)
val composeTestRule = createAndroidComposeRule<MainActivity>() val composeTestRule = createAndroidComposeRule<MainActivity>()
@Inject @Inject
lateinit var topicsRepository: TopicsRepository lateinit var topicsRepository: TopicsRepository
private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = @Inject
ReadOnlyProperty<Any, String> { _, _ -> activity.getString(resId) } lateinit var newsRepository: NewsRepository
// The strings used for matching in these tests // The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up) private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up)
@ -225,12 +215,7 @@ class NavigationTest {
onNodeWithText(ok).performClick() onNodeWithText(ok).performClick()
// Check that the saved screen is still visible and selected. // Check that the saved screen is still visible and selected.
onNode( onNode(hasText(saved) and hasTestTag("NiaNavItem")).assertIsSelected()
hasText(saved) and
hasAnyAncestor(
hasTestTag("NiaBottomBar") or hasTestTag("NiaNavRail"),
),
).assertIsSelected()
} }
} }
@ -289,4 +274,44 @@ class NavigationTest {
onNodeWithTag("topic:${topic.id}").assertExists() onNodeWithTag("topic:${topic.id}").assertExists()
} }
} }
@Test
fun navigatingToTopicFromForYou_showsTopicDetails() {
composeTestRule.apply {
// Get the first news resource
val newsResource = runBlocking {
newsRepository.getNewsResources().first().first()
}
// Get its first topic and follow it
val topic = newsResource.topics.first()
onNodeWithText(topic.name).performClick()
// Get the news feed and scroll to the news resource
// Note: Possible flakiness. If the content of the news resource is long then the topic
// tag might not be visible meaning it cannot be clicked
onNodeWithTag("forYou:feed")
.performScrollToNode(hasTestTag("newsResourceCard:${newsResource.id}"))
.fetchSemanticsNode()
.apply {
val newsResourceCardNode = onNodeWithTag("newsResourceCard:${newsResource.id}")
.fetchSemanticsNode()
config[ScrollBy].action?.invoke(
0f,
// to ensure the bottom of the card is visible,
// manually scroll the difference between the height of
// the scrolling node and the height of the card
(newsResourceCardNode.size.height - size.height).coerceAtLeast(0).toFloat(),
)
}
// Click the first topic tag
onAllNodesWithTag("topicTag:${topic.id}", useUnmergedTree = true)
.onFirst()
.performClick()
// Verify that we're on the correct topic details screen
onNodeWithTag("topic:${topic.id}").assertExists()
}
}
} }

@ -1,228 +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.runtime.Composable
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Dp
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.data.util.TimeZoneMonitor
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
@Inject
lateinit var timeZoneMonitor: TimeZoneMonitor
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun compactWidth_compactHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_mediumHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_expandedHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(fakeAppState(maxWidth, maxHeight))
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Composable
private fun fakeAppState(maxWidth: Dp, maxHeight: Dp) = rememberNiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
)
}

@ -16,15 +16,11 @@
package com.google.samples.apps.nowinandroid.ui 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule 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.NavHostController
import androidx.navigation.compose.ComposeNavigator import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@ -43,7 +39,6 @@ import kotlinx.datetime.TimeZone
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
/** /**
@ -52,7 +47,6 @@ import kotlin.test.assertTrue
* Note: This could become an unit test if Robolectric is added to the project and the Context * Note: This could become an unit test if Robolectric is added to the project and the Context
* is faked. * is faked.
*/ */
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
class NiaAppStateTest { class NiaAppStateTest {
@get:Rule @get:Rule
@ -79,7 +73,6 @@ class NiaAppStateTest {
NiaAppState( NiaAppState(
navController = navController, navController = navController,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
@ -102,7 +95,6 @@ class NiaAppStateTest {
fun niaAppState_destinations() = runTest { fun niaAppState_destinations() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = rememberNiaAppState( state = rememberNiaAppState(
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
@ -115,64 +107,12 @@ class NiaAppStateTest {
assertTrue(state.topLevelDestinations[2].name.contains("interests", true)) assertTrue(state.topLevelDestinations[2].name.contains("interests", true))
} }
@Test
fun niaAppState_showBottomBar_compact() = runTest {
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,
timeZoneMonitor = timeZoneMonitor,
)
}
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,
timeZoneMonitor = timeZoneMonitor,
)
}
assertTrue(state.shouldShowNavRail)
assertFalse(state.shouldShowBottomBar)
}
@Test @Test
fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) { fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
@ -193,7 +133,6 @@ class NiaAppStateTest {
state = NiaAppState( state = NiaAppState(
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
@ -207,8 +146,6 @@ class NiaAppStateTest {
state.currentTimeZone.value, state.currentTimeZone.value,
) )
} }
private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp))
} }
@Composable @Composable

@ -0,0 +1,26 @@
/*
* 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 androidx.annotation.StringRes
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import kotlin.properties.ReadOnlyProperty
fun AndroidComposeTestRule<*, *>.stringResource(
@StringRes resId: Int,
): ReadOnlyProperty<Any, String> =
ReadOnlyProperty { _, _ -> activity.getString(resId) }

@ -20,11 +20,13 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- <!--
Firebase automatically adds the AD_ID permission, even though we don't use it. If you use this Firebase automatically adds these AD_ID and ADSERVICES permissions, even though we don't use them.
permission you must declare how you're using it to Google Play, otherwise the app will be If you use these permissions you must declare how you're using them to Google Play, otherwise the
rejected when publishing it. To avoid this we remove the permission entirely. app will be rejected when publishing it. To avoid this we remove the permissions entirely.
--> -->
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/> <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove"/>
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_ATTRIBUTION" tools:node="remove"/>
<uses-permission android:name="android.permission.ACCESS_ADSERVICES_AD_ID" tools:node="remove"/>
<application <application
android:name=".NiaApplication" android:name=".NiaApplication"
@ -46,9 +48,13 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter> <intent-filter>
<data <action android:name="android.intent.action.VIEW" />
android:scheme="https"
android:host="www.nowinandroid.apps.samples.google.com" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="www.nowinandroid.apps.samples.google.com" />
</intent-filter> </intent-filter>
</activity> </activity>
@ -57,6 +63,11 @@
<meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" /> <meta-data android:name="firebase_analytics_collection_deactivated" android:value="true" />
<!-- Disable collection of AD_ID for all build variants --> <!-- Disable collection of AD_ID for all build variants -->
<meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" /> <meta-data android:name="google_analytics_adid_collection_enabled" android:value="false" />
<!-- Firebase automatically adds the following property which we don't use so remove it -->
<property
android:name="android.adservices.AD_SERVICES_CONFIG"
tools:node="remove" />
</application> </application>
</manifest> </manifest>

File diff suppressed because it is too large Load Diff

@ -22,8 +22,6 @@ import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -54,7 +52,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -76,7 +73,8 @@ class MainActivity : ComponentActivity() {
@Inject @Inject
lateinit var userNewsResourceRepository: UserNewsResourceRepository lateinit var userNewsResourceRepository: UserNewsResourceRepository
val viewModel: MainActivityViewModel by viewModels() private val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -136,7 +134,6 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val appState = rememberNiaAppState( val appState = rememberNiaAppState(
windowSizeClass = calculateWindowSizeClass(this),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,

@ -17,6 +17,9 @@
package com.google.samples.apps.nowinandroid package com.google.samples.apps.nowinandroid
import android.app.Application import android.app.Application
import android.content.pm.ApplicationInfo
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy.Builder
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import com.google.samples.apps.nowinandroid.sync.initializers.Sync import com.google.samples.apps.nowinandroid.sync.initializers.Sync
@ -37,10 +40,34 @@ class NiaApplication : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
setStrictModePolicy()
// Initialize Sync; the system responsible for keeping data in the app up to date. // Initialize Sync; the system responsible for keeping data in the app up to date.
Sync.initialize(context = this) Sync.initialize(context = this)
profileVerifierLogger() profileVerifierLogger()
} }
override fun newImageLoader(): ImageLoader = imageLoader.get() override fun newImageLoader(): ImageLoader = imageLoader.get()
/**
* Return true if the application is debuggable.
*/
private fun isDebuggable(): Boolean {
return 0 != applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE
}
/**
* Set a thread policy that detects all potential problems on the main thread, such as network
* and disk access.
*
* If a problem is found, the offending call will be logged and the application will be killed.
*/
private fun setStrictModePolicy() {
if (isDebuggable()) {
StrictMode.setThreadPolicy(
Builder().detectAll().penaltyLog().penaltyDeath().build(),
)
}
}
} }

@ -20,10 +20,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
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.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState import com.google.samples.apps.nowinandroid.ui.NiaAppState
import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen
@ -40,15 +42,22 @@ fun NiaNavHost(
appState: NiaAppState, appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = FOR_YOU_ROUTE,
) { ) {
val navController = appState.navController val navController = appState.navController
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination, startDestination = ForYouBaseRoute,
modifier = modifier, modifier = modifier,
) { ) {
forYouScreen(onTopicClick = navController::navigateToInterests) forYouSection(
onTopicClick = navController::navigateToTopic,
) {
topicScreen(
showBackButton = true,
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
}
bookmarksScreen( bookmarksScreen(
onTopicClick = navController::navigateToInterests, onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar, onShowSnackbar = onShowSnackbar,

@ -16,40 +16,61 @@
package com.google.samples.apps.nowinandroid.navigation package com.google.samples.apps.nowinandroid.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.R as searchR import com.google.samples.apps.nowinandroid.feature.search.R as searchR
/** /**
* Type for the top level destinations in the application. Each of these destinations * Type for the top level destinations in the application. Contains metadata about the destination
* can contain one or more screens (based on the window size). Navigation from one screen to the * that is used in the top app bar and common navigation UI.
* next within a single destination will be handled directly in composables. *
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
* @param route The route to use when navigating to this destination.
* @param baseRoute The highest ancestor of this destination. Defaults to [route], meaning that
* there is a single destination in that section of the app (no nested destinations).
*/ */
enum class TopLevelDestination( enum class TopLevelDestination(
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
val unselectedIcon: ImageVector, val unselectedIcon: ImageVector,
val iconTextId: Int, @StringRes val iconTextId: Int,
val titleTextId: Int, @StringRes val titleTextId: Int,
val route: KClass<*>,
val baseRoute: KClass<*> = route,
) { ) {
FOR_YOU( FOR_YOU(
selectedIcon = NiaIcons.Upcoming, selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder, unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title, iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name, titleTextId = R.string.app_name,
route = ForYouRoute::class,
baseRoute = ForYouBaseRoute::class,
), ),
BOOKMARKS( BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks, selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder, unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title, iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title, titleTextId = bookmarksR.string.feature_bookmarks_title,
route = BookmarksRoute::class,
), ),
INTERESTS( INTERESTS(
selectedIcon = NiaIcons.Grid3x3, selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3, unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests, iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests, titleTextId = searchR.string.feature_search_interests,
route = InterestsRoute::class,
), ),
} }

@ -16,8 +16,8 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -38,6 +37,8 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -58,14 +59,12 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground 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.NiaNavigationSuiteScaffold
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.NiaTopAppBar 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.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
@ -73,19 +72,20 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradien
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::class,
)
@Composable @Composable
fun NiaApp(appState: NiaAppState) { fun NiaApp(
appState: NiaAppState,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val shouldShowGradientBackground = val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
var showSettingsDialog by rememberSaveable { mutableStateOf(false) } var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
NiaBackground { NiaBackground(modifier = modifier) {
NiaGradientBackground( NiaGradientBackground(
gradientColors = if (shouldShowGradientBackground) { gradientColors = if (shouldShowGradientBackground) {
LocalGradientColors.current LocalGradientColors.current
@ -108,35 +108,83 @@ fun NiaApp(appState: NiaAppState) {
} }
} }
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = showSettingsDialog,
onSettingsDismissed = { showSettingsDialog = false },
onTopAppBarActionClick = { showSettingsDialog = true },
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
}
}
@Composable
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::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
if (showSettingsDialog) { if (showSettingsDialog) {
SettingsDialog( SettingsDialog(
onDismiss = { showSettingsDialog = false }, onDismiss = { onSettingsDismissed() },
) )
} }
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() NiaNavigationSuiteScaffold(
navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination
.isRouteInHierarchy(destination.baseRoute)
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier =
Modifier
.testTag("NiaNavItem")
.then(if (hasUnread) Modifier.notificationDot() else Modifier),
)
}
},
windowAdaptiveInfo = windowAdaptiveInfo,
) {
Scaffold( Scaffold(
modifier = Modifier.semantics { modifier = modifier.semantics {
testTagsAsResourceId = true testTagsAsResourceId = true
}, },
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"),
)
}
},
) { padding -> ) { padding ->
Row( Column(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
@ -147,22 +195,12 @@ fun NiaApp(appState: NiaAppState) {
), ),
), ),
) { ) {
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. // Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination val destination = appState.currentTopLevelDestination
var shouldShowTopAppBar = false
if (destination != null) { if (destination != null) {
shouldShowTopAppBar = true
NiaTopAppBar( NiaTopAppBar(
titleRes = destination.titleTextId, titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search, navigationIcon = NiaIcons.Search,
@ -176,11 +214,21 @@ fun NiaApp(appState: NiaAppState) {
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
), ),
onActionClick = { showSettingsDialog = true }, onActionClick = { onTopAppBarActionClick() },
onNavigationClick = { appState.navigateToSearch() }, onNavigationClick = { appState.navigateToSearch() },
) )
} }
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( NiaNavHost(
appState = appState, appState = appState,
onShowSnackbar = { message, action -> onShowSnackbar = { message, action ->
@ -199,77 +247,6 @@ fun NiaApp(appState: NiaAppState) {
} }
} }
} }
}
@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,
)
}
}
}
private fun Modifier.notificationDot(): Modifier = private fun Modifier.notificationDot(): Modifier =
composed { composed {
@ -290,7 +267,7 @@ private fun Modifier.notificationDot(): Modifier =
} }
} }
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
this?.hierarchy?.any { this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false it.hasRoute(route)
} ?: false } ?: false

@ -16,17 +16,17 @@
package com.google.samples.apps.nowinandroid.ui 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.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
import androidx.tracing.trace import androidx.tracing.trace
@ -34,11 +34,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BOOKMARKS_ROUTE
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@ -55,7 +52,6 @@ import kotlinx.datetime.TimeZone
@Composable @Composable
fun rememberNiaAppState( fun rememberNiaAppState(
windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor, timeZoneMonitor: TimeZoneMonitor,
@ -66,7 +62,6 @@ fun rememberNiaAppState(
return remember( return remember(
navController, navController,
coroutineScope, coroutineScope,
windowSizeClass,
networkMonitor, networkMonitor,
userNewsResourceRepository, userNewsResourceRepository,
timeZoneMonitor, timeZoneMonitor,
@ -74,7 +69,6 @@ fun rememberNiaAppState(
NiaAppState( NiaAppState(
navController = navController, navController = navController,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
windowSizeClass = windowSizeClass,
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
@ -86,28 +80,32 @@ fun rememberNiaAppState(
class NiaAppState( class NiaAppState(
val navController: NavHostController, val navController: NavHostController,
coroutineScope: CoroutineScope, coroutineScope: CoroutineScope,
val windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor, timeZoneMonitor: TimeZoneMonitor,
) { ) {
private val previousDestination = mutableStateOf<NavDestination?>(null)
val currentDestination: NavDestination? val currentDestination: NavDestination?
@Composable get() = navController @Composable get() {
.currentBackStackEntryAsState().value?.destination // Collect the currentBackStackEntryFlow as a state
val currentEntry = navController.currentBackStackEntryFlow
.collectAsState(initial = null)
// Fallback to previousDestination if currentEntry is null
return currentEntry.value?.destination.also { destination ->
if (destination != null) {
previousDestination.value = destination
}
} ?: previousDestination.value
}
val currentTopLevelDestination: TopLevelDestination? val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) { @Composable get() {
FOR_YOU_ROUTE -> FOR_YOU return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
BOOKMARKS_ROUTE -> BOOKMARKS currentDestination?.hasRoute(route = topLevelDestination.route) == true
INTERESTS_ROUTE -> INTERESTS }
else -> null
} }
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar
val isOffline = networkMonitor.isOnline val isOffline = networkMonitor.isOnline
.map(Boolean::not) .map(Boolean::not)

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

@ -17,44 +17,49 @@
package com.google.samples.apps.nowinandroid.ui.interests2pane package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.Keep
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ROUTE import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import kotlinx.serialization.Serializable
import java.util.UUID
private const val DETAIL_PANE_NAVHOST_ROUTE = "detail_pane_route" @Serializable internal object TopicPlaceholderRoute
// TODO: Remove @Keep when https://issuetracker.google.com/353898971 is fixed
@Keep
@Serializable internal object DetailPaneNavHostRoute
fun NavGraphBuilder.interestsListDetailScreen() { fun NavGraphBuilder.interestsListDetailScreen() {
composable( composable<InterestsRoute> {
route = INTERESTS_ROUTE,
arguments = listOf(
navArgument(TOPIC_ID_ARG) {
type = NavType.StringType
defaultValue = null
nullable = true
},
),
) {
InterestsListDetailScreen() InterestsListDetailScreen()
} }
} }
@ -62,11 +67,13 @@ fun NavGraphBuilder.interestsListDetailScreen() {
@Composable @Composable
internal fun InterestsListDetailScreen( internal fun InterestsListDetailScreen(
viewModel: Interests2PaneViewModel = hiltViewModel(), viewModel: Interests2PaneViewModel = hiltViewModel(),
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) { ) {
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle() val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
InterestsListDetailScreen( InterestsListDetailScreen(
selectedTopicId = selectedTopicId, selectedTopicId = selectedTopicId,
onTopicClick = viewModel::onTopicClick, onTopicClick = viewModel::onTopicClick,
windowAdaptiveInfo = windowAdaptiveInfo,
) )
} }
@ -75,18 +82,46 @@ internal fun InterestsListDetailScreen(
internal fun InterestsListDetailScreen( internal fun InterestsListDetailScreen(
selectedTopicId: String?, selectedTopicId: String?,
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,
windowAdaptiveInfo: WindowAdaptiveInfo,
) { ) {
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator<Nothing>() val listDetailNavigator = rememberListDetailPaneScaffoldNavigator(
scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo),
initialDestinationHistory = listOfNotNull(
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
ThreePaneScaffoldDestinationItem<Nothing>(ListDetailPaneScaffoldRole.Detail).takeIf {
selectedTopicId != null
},
),
)
BackHandler(listDetailNavigator.canNavigateBack()) { BackHandler(listDetailNavigator.canNavigateBack()) {
listDetailNavigator.navigateBack() listDetailNavigator.navigateBack()
} }
val nestedNavController = rememberNavController() var nestedNavHostStartRoute by remember {
val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
mutableStateOf(route)
}
var nestedNavKey by rememberSaveable(
stateSaver = Saver({ it.toString() }, UUID::fromString),
) {
mutableStateOf(UUID.randomUUID())
}
val nestedNavController = key(nestedNavKey) {
rememberNavController()
}
fun onTopicClickShowDetailPane(topicId: String) { fun onTopicClickShowDetailPane(topicId: String) {
onTopicClick(topicId) onTopicClick(topicId)
if (listDetailNavigator.isDetailPaneVisible()) {
// If the detail pane was visible, then use the nestedNavController navigate call
// directly
nestedNavController.navigateToTopic(topicId) { nestedNavController.navigateToTopic(topicId) {
popUpTo(DETAIL_PANE_NAVHOST_ROUTE) popUpTo<DetailPaneNavHostRoute>()
}
} else {
// Otherwise, recreate the NavHost entirely, and start at the new destination
nestedNavHostStartRoute = TopicRoute(id = topicId)
nestedNavKey = UUID.randomUUID()
} }
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
} }
@ -95,34 +130,34 @@ internal fun InterestsListDetailScreen(
value = listDetailNavigator.scaffoldValue, value = listDetailNavigator.scaffoldValue,
directive = listDetailNavigator.scaffoldDirective, directive = listDetailNavigator.scaffoldDirective,
listPane = { listPane = {
AnimatedPane {
InterestsRoute( InterestsRoute(
onTopicClick = ::onTopicClickShowDetailPane, onTopicClick = ::onTopicClickShowDetailPane,
highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(), highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
) )
}
}, },
detailPane = { detailPane = {
AnimatedPane {
key(nestedNavKey) {
NavHost( NavHost(
navController = nestedNavController, navController = nestedNavController,
startDestination = TOPIC_ROUTE, startDestination = nestedNavHostStartRoute,
route = DETAIL_PANE_NAVHOST_ROUTE, route = DetailPaneNavHostRoute::class,
) { ) {
topicScreen( topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(), showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack, onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane, onTopicClick = ::onTopicClickShowDetailPane,
) )
composable(route = TOPIC_ROUTE) { composable<TopicPlaceholderRoute> {
TopicDetailPlaceholder() TopicDetailPlaceholder()
} }
} }
},
)
LaunchedEffect(Unit) {
if (selectedTopicId != null) {
// Initial topic ID was provided when navigating to Interests, so show its details.
onTopicClickShowDetailPane(selectedTopicId)
} }
} }
},
)
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)

@ -15,9 +15,6 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<!-- Status bar -->
<color name="black30">#4D000000</color>
<color name="ic_launcher_background_tint">#000000</color> <color name="ic_launcher_background_tint">#000000</color>
<color name="ic_launcher_foreground_tint">#FCFCFC</color> <color name="ic_launcher_foreground_tint">#FCFCFC</color>
</resources> </resources>

@ -0,0 +1,67 @@
/*
* 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 androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.children
/**
* A [DeviceConfigurationOverride] that overrides the window insets for the contained content.
*/
@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 : AbstractComposeView(context) {
@Composable
override fun Content() {
currentContentUnderTest()
}
override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
children.forEach {
it.dispatchApplyWindowInsets(
WindowInsets(currentWindowInsets.toWindowInsets()),
)
}
return WindowInsetsCompat.CONSUMED.toWindowInsets()!!
}
/**
* Deprecated, but intercept the `requestApplyInsets` call via the deprecated
* method.
*/
@Deprecated("Deprecated in Java")
override fun requestFitSystemWindows() {
dispatchApplyWindowInsets(WindowInsets(currentWindowInsets.toWindowInsets()!!))
}
}
},
update = { with(currentWindowInsets) { it.requestApplyInsets() } },
)
}

@ -0,0 +1,204 @@
/*
* 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 androidx.activity.compose.BackHandler
import androidx.annotation.StringRes
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.ui.interests2pane.InterestsListDetailScreen
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty
import kotlin.test.assertTrue
import com.google.samples.apps.nowinandroid.feature.topic.R as FeatureTopicR
private const val EXPANDED_WIDTH = "w1200dp-h840dp"
private const val COMPACT_WIDTH = "w412dp-h915dp"
@HiltAndroidTest
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
class InterestsListDetailScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
@Inject
lateinit var topicsRepository: TopicsRepository
/** Convenience function for getting all topics during tests, */
private fun getTopics(): List<Topic> = runBlocking {
topicsRepository.getTopics().first().sortedBy { it.name }
}
// The strings used for matching in these tests.
private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest)
private val listPaneTag = "interests:topics"
private val Topic.testTag
get() = "topic:${this.id}"
@Before
fun setup() {
hiltRule.inject()
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_initialState_showsListPane() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
}
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_topicSelected_updatesDetailPane() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_topicSelected_showsTopicDetailPane() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
onNodeWithTag(listPaneTag).assertIsNotDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsDisplayed()
}
}
@Test
@Config(qualifiers = EXPANDED_WIDTH)
fun expandedWidth_backPressFromTopicDetail_leavesInterests() {
var unhandledBackPress = false
composeTestRule.apply {
setContent {
NiaTheme {
// Back press should not be handled by the two pane layout, and thus
// "fall through" to this BackHandler.
BackHandler {
unhandledBackPress = true
}
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack()
assertTrue(unhandledBackPress)
}
}
@Test
@Config(qualifiers = COMPACT_WIDTH)
fun compactWidth_backPressFromTopicDetail_showsListPane() {
composeTestRule.apply {
setContent {
NiaTheme {
InterestsListDetailScreen()
}
}
val firstTopic = getTopics().first()
onNodeWithText(firstTopic.name).performClick()
waitForIdle()
Espresso.pressBack()
onNodeWithTag(listPaneTag).assertIsDisplayed()
onNodeWithText(placeholderText).assertIsNotDisplayed()
onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed()
}
}
}
private fun AndroidComposeTestRule<*, *>.stringResource(
@StringRes resId: Int,
): ReadOnlyProperty<Any, String> =
ReadOnlyProperty { _, _ -> activity.getString(resId) }

@ -16,23 +16,19 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import android.util.Log import androidx.compose.material3.adaptive.Posture
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode 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.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.test.platform.app.InstrumentationRegistry import androidx.window.core.layout.WindowSizeClass
import androidx.work.Configuration
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import com.github.takahirom.roborazzi.captureRoboImage 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.TopicsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
@ -41,7 +37,6 @@ 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.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication import dagger.hilt.android.testing.HiltTestApplication
@ -50,7 +45,6 @@ import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
@ -62,7 +56,6 @@ import javax.inject.Inject
/** /**
* Tests that the navigation UI is rendered correctly on different screen sizes. * Tests that the navigation UI is rendered correctly on different screen sizes.
*/ */
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
// Configure Robolectric to use a very large screen size that can fit all of the test sizes. // Configure Robolectric to use a very large screen size that can fit all of the test sizes.
@ -78,18 +71,10 @@ class NiaAppScreenSizesScreenshotTests {
@get:Rule(order = 0) @get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this) 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. * Use a test activity to set the content on.
*/ */
@get:Rule(order = 2) @get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>() val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
@Inject @Inject
@ -109,17 +94,6 @@ class NiaAppScreenSizesScreenshotTests {
@Before @Before
fun setup() { 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() hiltRule.inject()
// Configure user data // Configure user data
@ -143,19 +117,25 @@ class NiaAppScreenSizesScreenshotTests {
CompositionLocalProvider( CompositionLocalProvider(
LocalInspectionMode provides true, LocalInspectionMode provides true,
) { ) {
TestHarness(size = DpSize(width, height)) { DeviceConfigurationOverride(
BoxWithConstraints { override = DeviceConfigurationOverride.ForcedSize(DpSize(width, height)),
) {
NiaTheme { NiaTheme {
val fakeAppState = rememberNiaAppState( val fakeAppState = rememberNiaAppState(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor, timeZoneMonitor = timeZoneMonitor,
) )
NiaApp(fakeAppState) NiaApp(
} fakeAppState,
windowAdaptiveInfo = WindowAdaptiveInfo(
windowSizeClass = WindowSizeClass.compute(
width.value,
height.value,
),
windowPosture = Posture(),
),
)
} }
} }
} }
@ -178,20 +158,20 @@ class NiaAppScreenSizesScreenshotTests {
} }
@Test @Test
fun mediumWidth_compactHeight_showsNavigationRail() { fun mediumWidth_compactHeight_showsNavigationBar() {
testNiaAppScreenshotWithSize( testNiaAppScreenshotWithSize(
610.dp, 610.dp,
400.dp, 400.dp,
"mediumWidth_compactHeight_showsNavigationRail", "mediumWidth_compactHeight_showsNavigationBar",
) )
} }
@Test @Test
fun expandedWidth_compactHeight_showsNavigationRail() { fun expandedWidth_compactHeight_showsNavigationBar() {
testNiaAppScreenshotWithSize( testNiaAppScreenshotWithSize(
900.dp, 900.dp,
400.dp, 400.dp,
"expandedWidth_compactHeight_showsNavigationRail", "expandedWidth_compactHeight_showsNavigationBar",
) )
} }

@ -0,0 +1,337 @@
/*
* 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.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.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.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)
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 1)
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,
)
}
}
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,239 @@
/*
* 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.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.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.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)
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 1)
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,
)
}
}
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: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 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: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 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: 94 KiB

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

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

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

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
@ -28,15 +29,17 @@ java {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
tasks.withType<KotlinCompile>().configureEach {
kotlinOptions { kotlin {
jvmTarget = JavaVersion.VERSION_17.toString() compilerOptions {
jvmTarget = JvmTarget.JVM_17
} }
} }
dependencies { dependencies {
compileOnly(libs.android.gradlePlugin) compileOnly(libs.android.gradlePlugin)
compileOnly(libs.android.tools.common) compileOnly(libs.android.tools.common)
compileOnly(libs.compose.gradlePlugin)
compileOnly(libs.firebase.crashlytics.gradlePlugin) compileOnly(libs.firebase.crashlytics.gradlePlugin)
compileOnly(libs.firebase.performance.gradlePlugin) compileOnly(libs.firebase.performance.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin)
@ -86,9 +89,9 @@ gradlePlugin {
id = "nowinandroid.android.test" id = "nowinandroid.android.test"
implementationClass = "AndroidTestConventionPlugin" implementationClass = "AndroidTestConventionPlugin"
} }
register("androidHilt") { register("hilt") {
id = "nowinandroid.android.hilt" id = "nowinandroid.hilt"
implementationClass = "AndroidHiltConventionPlugin" implementationClass = "HiltConventionPlugin"
} }
register("androidRoom") { register("androidRoom") {
id = "nowinandroid.android.room" id = "nowinandroid.android.room"

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

@ -21,6 +21,7 @@ import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.exclude
class AndroidApplicationFirebaseConventionPlugin : Plugin<Project> { class AndroidApplicationFirebaseConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
@ -35,7 +36,16 @@ class AndroidApplicationFirebaseConventionPlugin : Plugin<Project> {
val bom = libs.findLibrary("firebase-bom").get() val bom = libs.findLibrary("firebase-bom").get()
add("implementation", platform(bom)) add("implementation", platform(bom))
"implementation"(libs.findLibrary("firebase.analytics").get()) "implementation"(libs.findLibrary("firebase.analytics").get())
"implementation"(libs.findLibrary("firebase.performance").get()) "implementation"(libs.findLibrary("firebase.performance").get()) {
/*
Exclusion of protobuf / protolite dependencies is necessary as the
datastore-proto brings in protobuf dependencies. These are the source of truth
for Now in Android.
That's why the duplicate classes from below dependencies are excluded.
*/
exclude(group = "com.google.protobuf", module = "protobuf-javalite")
exclude(group = "com.google.firebase", module = "protolite-well-known-types")
}
"implementation"(libs.findLibrary("firebase.crashlytics").get()) "implementation"(libs.findLibrary("firebase.crashlytics").get())
} }

@ -27,13 +27,10 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
with(target) { with(target) {
pluginManager.apply { pluginManager.apply {
apply("nowinandroid.android.library") apply("nowinandroid.android.library")
apply("nowinandroid.android.hilt") apply("nowinandroid.hilt")
apply("org.jetbrains.kotlin.plugin.serialization")
} }
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
defaultConfig {
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
testOptions.animationsDisabled = true testOptions.animationsDisabled = true
configureGradleManagedDevices(this) configureGradleManagedDevices(this)
} }
@ -45,8 +42,11 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get()) add("implementation", libs.findLibrary("androidx.hilt.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get()) add("implementation", libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get()) add("implementation", libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
add("implementation", libs.findLibrary("androidx.navigation.compose").get())
add("implementation", libs.findLibrary("androidx.tracing.ktx").get()) add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
add("implementation", libs.findLibrary("kotlinx.serialization.json").get())
add("testImplementation", libs.findLibrary("androidx.navigation.testing").get())
add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get()) add("androidTestImplementation", libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
} }
} }

@ -1,38 +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.
*/
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.devtools.ksp")
apply("dagger.hilt.android.plugin")
}
dependencies {
"implementation"(libs.findLibrary("hilt.android").get())
"ksp"(libs.findLibrary("hilt.compiler").get())
}
}
}
}

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

@ -40,6 +40,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
extensions.configure<LibraryExtension> { extensions.configure<LibraryExtension> {
configureKotlinAndroid(this) configureKotlinAndroid(this)
defaultConfig.targetSdk = 34 defaultConfig.targetSdk = 34
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testOptions.animationsDisabled = true testOptions.animationsDisabled = true
configureFlavors(this) configureFlavors(this)
configureGradleManagedDevices(this) configureGradleManagedDevices(this)
@ -52,7 +53,8 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
disableUnnecessaryAndroidTests(target) disableUnnecessaryAndroidTests(target)
} }
dependencies { dependencies {
add("testImplementation", kotlin("test")) add("androidTestImplementation", libs.findLibrary("kotlin.test").get())
add("testImplementation", libs.findLibrary("kotlin.test").get())
add("implementation", libs.findLibrary("androidx.tracing.ktx").get()) add("implementation", libs.findLibrary("androidx.tracing.ktx").get())
} }

@ -42,5 +42,7 @@ class AndroidLintConventionPlugin : Plugin<Project> {
private fun Lint.configure() { private fun Lint.configure() {
xmlReport = true xmlReport = true
sarifReport = true
checkDependencies = true checkDependencies = true
disable += "GradleDependency"
} }

@ -0,0 +1,47 @@
/*
* 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.
*/
import com.android.build.gradle.api.AndroidBasePlugin
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
class HiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
dependencies {
add("ksp", libs.findLibrary("hilt.compiler").get())
}
// Add support for Jvm Module, base on org.jetbrains.kotlin.jvm
pluginManager.withPlugin("org.jetbrains.kotlin.jvm") {
dependencies {
add("implementation", libs.findLibrary("hilt.core").get())
}
}
/** Add support for Android modules, based on [AndroidBasePlugin] */
pluginManager.withPlugin("com.android.base") {
pluginManager.apply("dagger.hilt.android.plugin")
dependencies {
add("implementation", libs.findLibrary("hilt.android").get())
}
}
}
}
}

@ -15,8 +15,11 @@
*/ */
import com.google.samples.apps.nowinandroid.configureKotlinJvm import com.google.samples.apps.nowinandroid.configureKotlinJvm
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.kotlin
class JvmLibraryConventionPlugin : Plugin<Project> { class JvmLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
@ -26,6 +29,9 @@ class JvmLibraryConventionPlugin : Plugin<Project> {
apply("nowinandroid.android.lint") apply("nowinandroid.android.lint")
} }
configureKotlinJvm() configureKotlinJvm()
dependencies {
add("testImplementation", libs.findLibrary("kotlin.test").get())
}
} }
} }
} }

@ -18,9 +18,11 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project 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.dependencies
import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/** /**
* Configure Compose-specific options * Configure Compose-specific options
@ -33,10 +35,6 @@ internal fun Project.configureAndroidCompose(
compose = true compose = true
} }
composeOptions {
kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString()
}
dependencies { dependencies {
val bom = libs.findLibrary("androidx-compose-bom").get() val bom = libs.findLibrary("androidx-compose-bom").get()
add("implementation", platform(bom)) add("implementation", platform(bom))
@ -53,41 +51,21 @@ internal fun Project.configureAndroidCompose(
} }
} }
tasks.withType<KotlinCompile>().configureEach { extensions.configure<ComposeCompilerGradlePluginExtension> {
kotlinOptions { fun Provider<String>.onlyIfTrue() = flatMap { provider { it.takeIf(String::toBoolean) } }
freeCompilerArgs += buildComposeMetricsParameters() fun Provider<*>.relativeToRootProject(dir: String) = flatMap {
freeCompilerArgs += stabilityConfiguration() rootProject.layout.buildDirectory.dir(projectDir.toRelativeString(rootDir))
} }.map { it.dir(dir) }
}
}
private fun Project.buildComposeMetricsParameters(): List<String> { project.providers.gradleProperty("enableComposeCompilerMetrics").onlyIfTrue()
val metricParameters = mutableListOf<String>() .relativeToRootProject("compose-metrics")
val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics") .let(metricsDestination::set)
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,
)
}
val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports") project.providers.gradleProperty("enableComposeCompilerReports").onlyIfTrue()
val enableReports = (enableReportsProvider.orNull == "true") .relativeToRootProject("compose-reports")
if (enableReports) { .let(reportsDestination::set)
val reportsFolder = buildDir.resolve("compose-reports").resolve(relativePath)
metricParameters.add("-P") stabilityConfigurationFile =
metricParameters.add( rootProject.layout.projectDirectory.file("compose_compiler_config.conf")
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath
)
} }
return metricParameters.toList()
} }
private fun Project.stabilityConfiguration() = listOf(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:stabilityConfigurationPath=${project.rootDir.absolutePath}/compose_compiler_config.conf",
)

@ -30,6 +30,6 @@ import org.gradle.api.Project
internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests( internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests(
project: Project, project: Project,
) = beforeVariants { ) = beforeVariants {
it.enableAndroidTest = it.enableAndroidTest it.androidTest.enable = it.androidTest.enable
&& project.projectDir.resolve("src/androidTest").exists() && project.projectDir.resolve("src/androidTest").exists()
} }

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid package com.google.samples.apps.nowinandroid
import com.android.SdkConstants
import com.android.build.api.artifact.SingleArtifact import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.BaseExtension import com.android.build.gradle.BaseExtension
import com.android.SdkConstants
import com.google.common.truth.Truth.assertWithMessage import com.google.common.truth.Truth.assertWithMessage
import org.gradle.api.DefaultTask import org.gradle.api.DefaultTask
import org.gradle.api.Project import org.gradle.api.Project
@ -35,11 +35,12 @@ import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.register
import org.gradle.language.base.plugins.LifecycleBasePlugin import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.process.ExecOperations import org.gradle.process.ExecOperations
import java.io.File import java.io.File
import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
@CacheableTask @CacheableTask
@ -106,6 +107,10 @@ abstract class CheckBadgingTask : DefaultTask() {
} }
} }
private fun String.capitalized() = replaceFirstChar {
if (it.isLowerCase()) it.titlecase() else it.toString()
}
fun Project.configureBadgingTasks( fun Project.configureBadgingTasks(
baseExtension: BaseExtension, baseExtension: BaseExtension,
componentsExtension: ApplicationAndroidComponentsExtension, componentsExtension: ApplicationAndroidComponentsExtension,
@ -117,23 +122,20 @@ fun Project.configureBadgingTasks(
val generateBadgingTaskName = "generate${capitalizedVariantName}Badging" val generateBadgingTaskName = "generate${capitalizedVariantName}Badging"
val generateBadging = val generateBadging =
tasks.register<GenerateBadgingTask>(generateBadgingTaskName) { tasks.register<GenerateBadgingTask>(generateBadgingTaskName) {
apk.set( apk = variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE)
variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE),
) aapt2Executable = File(
aapt2Executable.set(
File(
baseExtension.sdkDirectory, baseExtension.sdkDirectory,
"${SdkConstants.FD_BUILD_TOOLS}/" + "${SdkConstants.FD_BUILD_TOOLS}/" +
"${baseExtension.buildToolsVersion}/" + "${baseExtension.buildToolsVersion}/" +
SdkConstants.FN_AAPT2, SdkConstants.FN_AAPT2,
),
) )
badging.set(
project.layout.buildDirectory.file( badging = project.layout.buildDirectory.file(
"outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt", "outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt",
),
) )
} }
val updateBadgingTaskName = "update${capitalizedVariantName}Badging" val updateBadgingTaskName = "update${capitalizedVariantName}Badging"
@ -144,17 +146,14 @@ fun Project.configureBadgingTasks(
val checkBadgingTaskName = "check${capitalizedVariantName}Badging" val checkBadgingTaskName = "check${capitalizedVariantName}Badging"
tasks.register<CheckBadgingTask>(checkBadgingTaskName) { tasks.register<CheckBadgingTask>(checkBadgingTaskName) {
goldenBadging.set( goldenBadging = project.layout.projectDirectory.file("${variant.name}-badging.txt")
project.layout.projectDirectory.file("${variant.name}-badging.txt"),
) generatedBadging = generateBadging.get().badging
generatedBadging.set(
generateBadging.get().badging, this.updateBadgingTaskName = updateBadgingTaskName
)
this.updateBadgingTaskName.set(updateBadgingTaskName) output = project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName")
output.set(
project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName"),
)
} }
} }
} }

@ -24,6 +24,7 @@ import org.gradle.api.file.Directory
import org.gradle.api.file.RegularFile import org.gradle.api.file.RegularFile
import org.gradle.api.provider.ListProperty import org.gradle.api.provider.ListProperty
import org.gradle.api.tasks.testing.Test import org.gradle.api.tasks.testing.Test
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType import org.gradle.kotlin.dsl.withType
@ -66,9 +67,13 @@ internal fun Project.configureJacoco(
val myObjFactory = project.objects val myObjFactory = project.objects
val buildDir = layout.buildDirectory.get().asFile val buildDir = layout.buildDirectory.get().asFile
val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java) val allJars: ListProperty<RegularFile> = myObjFactory.listProperty(RegularFile::class.java)
val allDirectories: ListProperty<Directory> = myObjFactory.listProperty(Directory::class.java) val allDirectories: ListProperty<Directory> =
myObjFactory.listProperty(Directory::class.java)
val reportTask = val reportTask =
tasks.register("create${variant.name.capitalize()}CombinedCoverageReport", JacocoReport::class) { tasks.register(
"create${variant.name.capitalize()}CombinedCoverageReport",
JacocoReport::class,
) {
classDirectories.setFrom( classDirectories.setFrom(
allJars, allJars,
@ -76,22 +81,27 @@ internal fun Project.configureJacoco(
dirs.map { dir -> dirs.map { dir ->
myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions) myObjFactory.fileTree().setDir(dir).exclude(coverageExclusions)
} }
} },
) )
reports { reports {
xml.required.set(true) xml.required = true
html.required.set(true) html.required = true
} }
// TODO: This is missing files in src/debug/, src/prod, src/demo, src/demoDebug... // 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")) sourceDirectories.setFrom(
files(
"$projectDir/src/main/java",
"$projectDir/src/main/kotlin",
),
)
executionData.setFrom( executionData.setFrom(
project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest") project.fileTree("$buildDir/outputs/unit_test_code_coverage/${variant.name}UnitTest")
.matching { include("**/*.exec") }, .matching { include("**/*.exec") },
project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest") project.fileTree("$buildDir/outputs/code_coverage/${variant.name}AndroidTest")
.matching { include("**/*.ec") } .matching { include("**/*.ec") },
) )
} }

@ -20,11 +20,14 @@ import com.android.build.api.dsl.CommonExtension
import org.gradle.api.JavaVersion import org.gradle.api.JavaVersion
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.plugins.JavaPluginExtension import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.provideDelegate
import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 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 * Configure base Kotlin with Android options
@ -48,7 +51,7 @@ internal fun Project.configureKotlinAndroid(
} }
} }
configureKotlin() configureKotlin<KotlinAndroidProjectExtension>()
dependencies { dependencies {
add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get()) add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get())
@ -66,26 +69,40 @@ internal fun Project.configureKotlinJvm() {
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
configureKotlin() configureKotlin<KotlinJvmProjectExtension>()
} }
/** /**
* Configure base Kotlin options * Configure base Kotlin options
*/ */
private fun Project.configureKotlin() { private inline fun <reified T : KotlinTopLevelExtension> Project.configureKotlin() = configure<T> {
// 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) // Treat all Kotlin warnings as errors (disabled by default)
// Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
val warningsAsErrors: String? by project 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() allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs = freeCompilerArgs + listOf( freeCompilerArgs.add(
// Enable experimental coroutines APIs, including Flow // Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
) )
} freeCompilerArgs.add(
/**
* Remove this args after Phase 3.
* https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-consistent-copy-visibility/#deprecation-timeline
*
* Deprecation timeline
* Phase 3. (Supposedly Kotlin 2.2 or Kotlin 2.3).
* The default changes.
* Unless ExposedCopyVisibility is used, the generated 'copy' method has the same visibility as the primary constructor.
* The binary signature changes. The error on the declaration is no longer reported.
* '-Xconsistent-data-class-copy-visibility' compiler flag and ConsistentCopyVisibility annotation are now unnecessary.
*/
"-Xconsistent-data-class-copy-visibility"
)
} }
} }

@ -33,6 +33,7 @@ import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.assign
import org.gradle.work.DisableCachingByDefault import org.gradle.work.DisableCachingByDefault
import java.io.File import java.io.File
@ -53,12 +54,12 @@ internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtensio
if (artifact != null && testSources != null) { if (artifact != null && testSources != null) {
tasks.register( tasks.register(
"${variant.name}PrintTestApk", "${variant.name}PrintTestApk",
PrintApkLocationTask::class.java PrintApkLocationTask::class.java,
) { ) {
apkFolder.set(artifact) apkFolder = artifact
builtArtifactsLoader.set(loader) builtArtifactsLoader = loader
variantName.set(variant.name) variantName = variant.name
sources.set(testSources) sources = testSources
} }
} }
} }

@ -16,7 +16,13 @@
dependencyResolutionManagement { dependencyResolutionManagement {
repositories { repositories {
google() google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral() mavenCentral()
} }
versionCatalogs { versionCatalogs {

@ -16,7 +16,13 @@
buildscript { buildscript {
repositories { repositories {
google() google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral() mavenCentral()
// Android Build Server // Android Build Server
@ -30,12 +36,19 @@ buildscript {
} }
// Lists all plugins used throughout the project /*
* By listing all the plugins used throughout all subprojects in the root project build script, it
* ensures that the build script classpath remains the same for all projects. This avoids potential
* problems with mismatching versions of transitive plugin dependencies. A subproject that applies
* an unlisted plugin will have that plugin and its dependencies _appended_ to the classpath, not
* replacing pre-existing dependencies.
*/
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
alias(libs.plugins.android.test) apply false alias(libs.plugins.android.test) apply false
alias(libs.plugins.baselineprofile) 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.jvm) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.dependencyGuard) apply false alias(libs.plugins.dependencyGuard) apply false
@ -49,13 +62,3 @@ plugins {
alias(libs.plugins.room) apply false alias(libs.plugins.room) apply false
alias(libs.plugins.module.graph) apply true // Plugin applied to allow module graph generation alias(libs.plugins.module.graph) apply true // Plugin applied to allow module graph generation
} }
// Task to print all the module paths in the project e.g. :core:data
// Used by module graph generator script
tasks.register("printModulePaths") {
subprojects {
if (subprojects.size == 0) {
println(this.path)
}
}
}

@ -1,6 +1,11 @@
// This file contains classes (with possible wildcards) that the Compose Compiler will treat as stable. // 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. // It allows us to define classes that are 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 // 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.ZoneId
java.time.ZoneOffset java.time.ZoneOffset

@ -16,7 +16,7 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.compose)
alias(libs.plugins.nowinandroid.android.hilt) alias(libs.plugins.nowinandroid.hilt)
} }
android { android {

@ -16,9 +16,9 @@
package com.google.samples.apps.nowinandroid.core.analytics package com.google.samples.apps.nowinandroid.core.analytics
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.analytics import com.google.firebase.analytics.analytics
import com.google.firebase.ktx.Firebase
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -35,8 +35,6 @@ internal abstract class AnalyticsModule {
companion object { companion object {
@Provides @Provides
@Singleton @Singleton
fun provideFirebaseAnalytics(): FirebaseAnalytics { fun provideFirebaseAnalytics(): FirebaseAnalytics = Firebase.analytics
return Firebase.analytics
}
} }
} }

@ -17,7 +17,7 @@
package com.google.samples.apps.nowinandroid.core.analytics package com.google.samples.apps.nowinandroid.core.analytics
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.ktx.logEvent import com.google.firebase.analytics.logEvent
import javax.inject.Inject import javax.inject.Inject
/** /**

@ -14,16 +14,12 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.jvm.library)
alias(libs.plugins.nowinandroid.android.library.jacoco) alias(libs.plugins.nowinandroid.hilt)
alias(libs.plugins.nowinandroid.android.hilt)
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.common"
} }
dependencies { dependencies {
implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine) testImplementation(libs.turbine)
} }

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://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.
-->
<manifest />

@ -15,7 +15,7 @@
*/ */
plugins { plugins {
alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.hilt) alias(libs.plugins.nowinandroid.hilt)
} }
android { android {

@ -17,16 +17,13 @@
package com.google.samples.apps.nowinandroid.core.data.test.repository 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.Synchronizer
import com.google.samples.apps.nowinandroid.core.data.model.asEntity import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
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.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.Dispatcher 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.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource 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.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
@ -39,7 +36,7 @@ import javax.inject.Inject
* This allows us to run the app with fake data, without needing an internet connection or working * This allows us to run the app with fake data, without needing an internet connection or working
* backend. * backend.
*/ */
internal class FakeNewsRepository @Inject constructor( class FakeNewsRepository @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val datasource: DemoNiaNetworkDataSource, private val datasource: DemoNiaNetworkDataSource,
) : NewsRepository { ) : NewsRepository {
@ -48,9 +45,11 @@ internal class FakeNewsRepository @Inject constructor(
query: NewsResourceQuery, query: NewsResourceQuery,
): Flow<List<NewsResource>> = ): Flow<List<NewsResource>> =
flow { flow {
val newsResources = datasource.getNewsResources()
val topics = datasource.getTopics()
emit( emit(
datasource newsResources
.getNewsResources()
.filter { networkNewsResource -> .filter { networkNewsResource ->
// Filter out any news resources which don't match the current query. // Filter out any news resources which don't match the current query.
// If no query parameters (filterTopicIds or filterNewsIds) are specified // If no query parameters (filterTopicIds or filterNewsIds) are specified
@ -64,8 +63,7 @@ internal class FakeNewsRepository @Inject constructor(
) )
.all(true::equals) .all(true::equals)
} }
.map(NetworkNewsResource::asEntity) .map { it.asExternalModel(topics) },
.map(NewsResourceEntity::asExternalModel),
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)

@ -30,7 +30,7 @@ import javax.inject.Inject
* This allows us to run the app with fake data, without needing an internet connection or working * This allows us to run the app with fake data, without needing an internet connection or working
* backend. * backend.
*/ */
internal class FakeUserDataRepository @Inject constructor( class FakeUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource, private val niaPreferencesDataSource: NiaPreferencesDataSource,
) : UserDataRepository { ) : UserDataRepository {

@ -16,7 +16,7 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.library.jacoco) alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.nowinandroid.android.hilt) alias(libs.plugins.nowinandroid.hilt)
id("kotlinx-serialization") id("kotlinx-serialization")
} }

@ -19,8 +19,10 @@ package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import com.google.samples.apps.nowinandroid.core.network.model.asExternalModel
fun NetworkNewsResource.asEntity() = NewsResourceEntity( fun NetworkNewsResource.asEntity() = NewsResourceEntity(
id = id, id = id,
@ -32,16 +34,6 @@ fun NetworkNewsResource.asEntity() = NewsResourceEntity(
type = type, type = type,
) )
fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
id = id,
title = title,
content = content,
url = url,
headerImageUrl = headerImageUrl,
publishDate = publishDate,
type = type,
)
/** /**
* A shell [TopicEntity] to fulfill the foreign key constraint when inserting * A shell [TopicEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB * a [NewsResourceEntity] into the DB
@ -65,3 +57,17 @@ fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef>
topicId = topicId, topicId = topicId,
) )
} }
fun NetworkNewsResource.asExternalModel(topics: List<NetworkTopic>) =
NewsResource(
id = id,
title = title,
content = content,
url = url,
headerImageUrl = headerImageUrl,
publishDate = publishDate,
type = type,
topics = topics
.filter { networkTopic -> this.topics.contains(networkTopic.id) }
.map(NetworkTopic::asExternalModel),
)

@ -1,91 +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.core.data.model
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertEquals
class NetworkEntityKtTest {
@Test
fun network_topic_can_be_mapped_to_topic_entity() {
val networkModel = NetworkTopic(
id = "0",
name = "Test",
shortDescription = "short description",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
)
val entity = networkModel.asEntity()
assertEquals("0", entity.id)
assertEquals("Test", entity.name)
assertEquals("short description", entity.shortDescription)
assertEquals("long description", entity.longDescription)
assertEquals("URL", entity.url)
assertEquals("image URL", entity.imageUrl)
}
@Test
fun network_news_resource_can_be_mapped_to_news_resource_entity() {
val networkModel =
NetworkNewsResource(
id = "0",
title = "title",
content = "content",
url = "url",
headerImageUrl = "headerImageUrl",
publishDate = Instant.fromEpochMilliseconds(1),
type = "Article 📚",
)
val entity = networkModel.asEntity()
assertEquals("0", entity.id)
assertEquals("title", entity.title)
assertEquals("content", entity.content)
assertEquals("url", entity.url)
assertEquals("headerImageUrl", entity.headerImageUrl)
assertEquals(Instant.fromEpochMilliseconds(1), entity.publishDate)
assertEquals("Article 📚", entity.type)
val expandedNetworkModel =
NetworkNewsResourceExpanded(
id = "0",
title = "title",
content = "content",
url = "url",
headerImageUrl = "headerImageUrl",
publishDate = Instant.fromEpochMilliseconds(1),
type = "Article 📚",
)
val entityFromExpanded = expandedNetworkModel.asEntity()
assertEquals("0", entityFromExpanded.id)
assertEquals("title", entityFromExpanded.title)
assertEquals("content", entityFromExpanded.content)
assertEquals("url", entityFromExpanded.url)
assertEquals("headerImageUrl", entityFromExpanded.headerImageUrl)
assertEquals(Instant.fromEpochMilliseconds(1), entityFromExpanded.publishDate)
assertEquals("Article 📚", entityFromExpanded.type)
}
}

@ -0,0 +1,140 @@
/*
* 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.core.data.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import com.google.samples.apps.nowinandroid.core.network.model.asExternalModel
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertEquals
class NetworkEntityTest {
@Test
fun networkTopicMapsToDatabaseModel() {
val networkModel = NetworkTopic(
id = "0",
name = "Test",
shortDescription = "short description",
longDescription = "long description",
url = "URL",
imageUrl = "image URL",
)
val entity = networkModel.asEntity()
assertEquals("0", entity.id)
assertEquals("Test", entity.name)
assertEquals("short description", entity.shortDescription)
assertEquals("long description", entity.longDescription)
assertEquals("URL", entity.url)
assertEquals("image URL", entity.imageUrl)
}
@Test
fun networkNewsResourceMapsToDatabaseModel() {
val networkModel =
NetworkNewsResource(
id = "0",
title = "title",
content = "content",
url = "url",
headerImageUrl = "headerImageUrl",
publishDate = Instant.fromEpochMilliseconds(1),
type = "Article 📚",
)
val entity = networkModel.asEntity()
assertEquals("0", entity.id)
assertEquals("title", entity.title)
assertEquals("content", entity.content)
assertEquals("url", entity.url)
assertEquals("headerImageUrl", entity.headerImageUrl)
assertEquals(Instant.fromEpochMilliseconds(1), entity.publishDate)
assertEquals("Article 📚", entity.type)
}
@Test
fun networkTopicMapsToExternalModel() {
val networkTopic = NetworkTopic(
id = "0",
name = "Test",
shortDescription = "short description",
longDescription = "long description",
url = "URL",
imageUrl = "imageUrl",
)
val expected = Topic(
id = "0",
name = "Test",
shortDescription = "short description",
longDescription = "long description",
url = "URL",
imageUrl = "imageUrl",
)
assertEquals(expected, networkTopic.asExternalModel())
}
@Test
fun networkNewsResourceMapsToExternalModel() {
val networkNewsResource = NetworkNewsResource(
id = "0",
title = "title",
content = "content",
url = "url",
headerImageUrl = "headerImageUrl",
publishDate = Instant.fromEpochMilliseconds(1),
type = "Article 📚",
topics = listOf("1", "2"),
)
val networkTopics = listOf(
NetworkTopic(
id = "1",
name = "Test 1",
shortDescription = "short description 1",
longDescription = "long description 1",
url = "url 1",
imageUrl = "imageUrl 1",
),
NetworkTopic(
id = "2",
name = "Test 2",
shortDescription = "short description 2",
longDescription = "long description 2",
url = "url 2",
imageUrl = "imageUrl 2",
),
)
val expected = NewsResource(
id = "0",
title = "title",
content = "content",
url = "url",
headerImageUrl = "headerImageUrl",
publishDate = Instant.fromEpochMilliseconds(1),
type = "Article 📚",
topics = networkTopics.map(NetworkTopic::asExternalModel),
)
assertEquals(expected, networkNewsResource.asExternalModel(networkTopics))
}
}

@ -32,7 +32,8 @@ import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsRes
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.test.InMemoryDataStore
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
@ -43,9 +44,7 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -67,14 +66,9 @@ class OfflineFirstNewsRepositoryTest {
private lateinit var synchronizer: Synchronizer private lateinit var synchronizer: Synchronizer
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before @Before
fun setup() { fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource( niaPreferencesDataSource = NiaPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance()))
tmpFolder.testUserPreferencesDataStore(testScope),
)
newsResourceDao = TestNewsResourceDao() newsResourceDao = TestNewsResourceDao()
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()

@ -25,7 +25,8 @@ import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.test.InMemoryDataStore
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -33,9 +34,7 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals import kotlin.test.assertEquals
class OfflineFirstTopicsRepositoryTest { class OfflineFirstTopicsRepositoryTest {
@ -52,16 +51,11 @@ class OfflineFirstTopicsRepositoryTest {
private lateinit var synchronizer: Synchronizer private lateinit var synchronizer: Synchronizer
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before @Before
fun setup() { fun setup() {
topicDao = TestTopicDao() topicDao = TestTopicDao()
network = TestNiaNetworkDataSource() network = TestNiaNetworkDataSource()
niaPreferences = NiaPreferencesDataSource( niaPreferences = NiaPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance()))
tmpFolder.testUserPreferencesDataStore(testScope),
)
synchronizer = TestSynchronizer(niaPreferences) synchronizer = TestSynchronizer(niaPreferences)
subject = OfflineFirstTopicsRepository( subject = OfflineFirstTopicsRepository(

@ -18,7 +18,8 @@ package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper
import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.test.InMemoryDataStore
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig 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.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
@ -28,9 +29,7 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -45,14 +44,9 @@ class OfflineFirstUserDataRepositoryTest {
private val analyticsHelper = NoOpAnalyticsHelper() private val analyticsHelper = NoOpAnalyticsHelper()
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before @Before
fun setup() { fun setup() {
niaPreferencesDataSource = NiaPreferencesDataSource( niaPreferencesDataSource = NiaPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance()))
tmpFolder.testUserPreferencesDataStore(testScope),
)
subject = OfflineFirstUserDataRepository( subject = OfflineFirstUserDataRepository(
niaPreferencesDataSource = niaPreferencesDataSource, niaPreferencesDataSource = niaPreferencesDataSource,

@ -17,15 +17,11 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.library.jacoco) alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.nowinandroid.android.hilt)
alias(libs.plugins.nowinandroid.android.room) alias(libs.plugins.nowinandroid.android.room)
alias(libs.plugins.nowinandroid.hilt)
} }
android { android {
defaultConfig {
testInstrumentationRunner =
"com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner"
}
namespace = "com.google.samples.apps.nowinandroid.core.database" namespace = "com.google.samples.apps.nowinandroid.core.database"
} }
@ -34,5 +30,7 @@ dependencies {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
androidTestImplementation(projects.core.testing) androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.kotlinx.coroutines.test)
} }

@ -15,7 +15,7 @@
*/ */
plugins { plugins {
alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.hilt) alias(libs.plugins.nowinandroid.hilt)
} }
android { android {

@ -0,0 +1,28 @@
/*
* 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.datastore.test
import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.updateAndGet
class InMemoryDataStore<T>(initialValue: T) : DataStore<T> {
override val data = MutableStateFlow(initialValue)
override suspend fun updateData(
transform: suspend (it: T) -> T,
) = data.updateAndGet { transform(it) }
}

@ -17,17 +17,13 @@
package com.google.samples.apps.nowinandroid.core.datastore.test package com.google.samples.apps.nowinandroid.core.datastore.test
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule import com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn import dagger.hilt.testing.TestInstallIn
import kotlinx.coroutines.CoroutineScope
import org.junit.rules.TemporaryFolder
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -36,26 +32,9 @@ import javax.inject.Singleton
replaces = [DataStoreModule::class], replaces = [DataStoreModule::class],
) )
internal object TestDataStoreModule { internal object TestDataStoreModule {
@Provides @Provides
@Singleton @Singleton
fun providesUserPreferencesDataStore( fun providesUserPreferencesDataStore(
@ApplicationScope scope: CoroutineScope, serializer: UserPreferencesSerializer,
userPreferencesSerializer: UserPreferencesSerializer, ): DataStore<UserPreferences> = InMemoryDataStore(serializer.defaultValue)
tmpFolder: TemporaryFolder,
): DataStore<UserPreferences> =
tmpFolder.testUserPreferencesDataStore(
coroutineScope = scope,
userPreferencesSerializer = userPreferencesSerializer,
)
}
fun TemporaryFolder.testUserPreferencesDataStore(
coroutineScope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(),
) = DataStoreFactory.create(
serializer = userPreferencesSerializer,
scope = coroutineScope,
) {
newFile("user_preferences_test.pb")
} }

@ -17,7 +17,7 @@
plugins { plugins {
alias(libs.plugins.nowinandroid.android.library) alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.nowinandroid.android.library.jacoco) alias(libs.plugins.nowinandroid.android.library.jacoco)
alias(libs.plugins.nowinandroid.android.hilt) alias(libs.plugins.nowinandroid.hilt)
} }
android { android {
@ -33,7 +33,7 @@ android {
} }
dependencies { dependencies {
api(libs.androidx.dataStore.core) api(libs.androidx.dataStore)
api(projects.core.datastoreProto) api(projects.core.datastoreProto)
api(projects.core.model) api(projects.core.model)

@ -16,15 +16,13 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.InMemoryDataStore
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -34,14 +32,9 @@ class NiaPreferencesDataSourceTest {
private lateinit var subject: NiaPreferencesDataSource private lateinit var subject: NiaPreferencesDataSource
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before @Before
fun setup() { fun setup() {
subject = NiaPreferencesDataSource( subject = NiaPreferencesDataSource(InMemoryDataStore(UserPreferences.getDefaultInstance()))
tmpFolder.testUserPreferencesDataStore(testScope),
)
} }
@Test @Test

@ -34,19 +34,19 @@ dependencies {
api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.foundation.layout)
api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material.iconsExtended)
api(libs.androidx.compose.material3) api(libs.androidx.compose.material3)
api(libs.androidx.compose.material3.adaptive)
api(libs.androidx.compose.material3.navigationSuite)
api(libs.androidx.compose.runtime) api(libs.androidx.compose.runtime)
api(libs.androidx.compose.ui.util) api(libs.androidx.compose.ui.util)
implementation(libs.coil.kt.compose) implementation(libs.coil.kt.compose)
testImplementation(libs.androidx.compose.ui.test) testImplementation(libs.androidx.compose.ui.test)
testImplementation(libs.accompanist.testharness) testImplementation(libs.androidx.compose.ui.testManifest)
testImplementation(libs.hilt.android.testing) testImplementation(libs.hilt.android.testing)
testImplementation(libs.robolectric) testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(projects.core.screenshotTesting) testImplementation(projects.core.screenshotTesting)
testImplementation(projects.core.testing)
androidTestImplementation(libs.androidx.compose.ui.test) androidTestImplementation(libs.bundles.androidx.compose.ui.test)
androidTestImplementation(projects.core.testing)
} }

@ -23,10 +23,18 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItemColors
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScope
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -165,6 +173,96 @@ fun NiaNavigationRail(
) )
} }
/**
* Now in Android navigation suite scaffold with item and content slots.
* Wraps Material 3 [NavigationSuiteScaffold].
*
* @param modifier Modifier to be applied to the navigation suite scaffold.
* @param navigationSuiteItems A slot to display multiple items via [NiaNavigationSuiteScope].
* @param windowAdaptiveInfo The window adaptive info.
* @param content The app content inside the scaffold.
*/
@Composable
fun NiaNavigationSuiteScaffold(
navigationSuiteItems: NiaNavigationSuiteScope.() -> Unit,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
content: @Composable () -> Unit,
) {
val layoutType = NavigationSuiteScaffoldDefaults
.calculateFromAdaptiveInfo(windowAdaptiveInfo)
val navigationSuiteItemColors = NavigationSuiteItemColors(
navigationBarItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(),
unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),
selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),
unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),
indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(),
),
navigationRailItemColors = NavigationRailItemDefaults.colors(
selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(),
unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),
selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),
unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),
indicatorColor = NiaNavigationDefaults.navigationIndicatorColor(),
),
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
selectedIconColor = NiaNavigationDefaults.navigationSelectedItemColor(),
unselectedIconColor = NiaNavigationDefaults.navigationContentColor(),
selectedTextColor = NiaNavigationDefaults.navigationSelectedItemColor(),
unselectedTextColor = NiaNavigationDefaults.navigationContentColor(),
),
)
NavigationSuiteScaffold(
navigationSuiteItems = {
NiaNavigationSuiteScope(
navigationSuiteScope = this,
navigationSuiteItemColors = navigationSuiteItemColors,
).run(navigationSuiteItems)
},
layoutType = layoutType,
containerColor = Color.Transparent,
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationBarContentColor = NiaNavigationDefaults.navigationContentColor(),
navigationRailContainerColor = Color.Transparent,
),
modifier = modifier,
) {
content()
}
}
/**
* A wrapper around [NavigationSuiteScope] to declare navigation items.
*/
class NiaNavigationSuiteScope internal constructor(
private val navigationSuiteScope: NavigationSuiteScope,
private val navigationSuiteItemColors: NavigationSuiteItemColors,
) {
fun item(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: @Composable () -> Unit,
selectedIcon: @Composable () -> Unit = icon,
label: @Composable (() -> Unit)? = null,
) = navigationSuiteScope.item(
selected = selected,
onClick = onClick,
icon = {
if (selected) {
selectedIcon()
} else {
icon()
}
},
label = label,
colors = navigationSuiteItemColors,
modifier = modifier,
)
}
@ThemePreviews @ThemePreviews
@Composable @Composable
fun NiaNavigationBarPreview() { fun NiaNavigationBarPreview() {

@ -23,7 +23,6 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Bookmarks import androidx.compose.material.icons.outlined.Bookmarks
import androidx.compose.material.icons.outlined.Upcoming import androidx.compose.material.icons.outlined.Upcoming
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.Bookmark
import androidx.compose.material.icons.rounded.BookmarkBorder import androidx.compose.material.icons.rounded.BookmarkBorder
import androidx.compose.material.icons.rounded.Bookmarks import androidx.compose.material.icons.rounded.Bookmarks
@ -33,7 +32,6 @@ import androidx.compose.material.icons.rounded.Grid3x3
import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Person
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.ShortText
import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material.icons.rounded.ViewDay import androidx.compose.material.icons.rounded.ViewDay
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector

@ -21,12 +21,15 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.DeviceConfigurationOverride
import androidx.compose.ui.test.FontScale
import androidx.compose.ui.test.ForcedSize
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.then
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage import com.github.takahirom.roborazzi.captureRoboImage
import com.google.accompanist.testharness.TestHarness
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
@ -78,7 +81,10 @@ class FilterChipScreenshotTests {
CompositionLocalProvider( CompositionLocalProvider(
LocalInspectionMode provides true, LocalInspectionMode provides true,
) { ) {
TestHarness(fontScale = 2f, size = DpSize(80.dp, 40.dp)) { DeviceConfigurationOverride(
DeviceConfigurationOverride.FontScale(2f) then
DeviceConfigurationOverride.ForcedSize(DpSize(80.dp, 40.dp)),
) {
NiaTheme { NiaTheme {
NiaBackground { NiaBackground {
NiaFilterChip(selected = true, onSelectedChange = {}) { NiaFilterChip(selected = true, onSelectedChange = {}) {

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

Loading…
Cancel
Save