Merge branch 'main'

Change-Id: I04c00490e97e0c41f5cc285df6a62014e6b12e47
feature/list-detail-pane-scaffold
Jonathan Koren 11 months ago
commit d12f5207a5

@ -4,3 +4,4 @@
[*.{kt,kts}] [*.{kt,kts}]
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

@ -0,0 +1,26 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
registries: "*"
labels: [ "version update" ]
groups:
kotlin-ksp-compose:
patterns:
- "org.jetbrains.kotlin:*"
- "org.jetbrains.kotlin.jvm"
- "com.google.devtools.ksp"
- "androidx.compose.compiler:compiler"
open-pull-requests-limit: 10
registries:
maven-google:
type: "maven-repository"
url: "https://maven.google.com"
replaces-base: true

@ -1,16 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base", "group:all", ":dependencyDashboard", "schedule:daily"
],
"packageRules": [
{
"matchPackageNames": ["org.objenesis:objenesis"],
"allowedVersions": "<=2.6"
},
{
"matchPackageNames": ["com.google.protobuf"],
"allowedVersions": "<=0.8.19"
}
]
}

@ -31,7 +31,7 @@ jobs:
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: 17 java-version: 17
@ -39,16 +39,47 @@ jobs:
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v2
- name: Check build-logic
run: ./gradlew check -p build-logic
- name: Check spotless - name: Check spotless
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
- name: Check Dependency Guard
id: dependencyguard_verify
continue-on-error: true
run: ./gradlew dependencyGuard
- name: Prevent updating Dependency Guard baselines if this is a fork
id: checkfork_dependencyguard
continue-on-error: false
if: steps.dependencyguard_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::Dependency Guard failed, please run the following to update baselines:\n" \
" ./gradlew dependencyGuardBaseline" && exit 1
# Runs if previous job failed
- name: Generate new screenshots if verification failed and it's a PR
id: dependencyguard_baseline
if: steps.dependencyguard_verify.outcome == 'failure' && github.event_name == 'pull_request'
run: |
./gradlew dependencyGuardBaseline
- name: Push new Dependency Guard baselines if available
uses: stefanzweifel/git-auto-commit-action@v5
if: steps.dependencyguard_baseline.outcome == 'success'
with:
file_pattern: '**/dependencies/*.txt'
disable_globbing: true
commit_message: "🤖 Updates baselines for Dependency Guard"
- name: Run all local screenshot tests (Roborazzi) - name: Run all local screenshot tests (Roborazzi)
id: screenshotsverify id: screenshotsverify
continue-on-error: true continue-on-error: true
run: ./gradlew verifyRoborazziDemoDebug run: ./gradlew verifyRoborazziDemoDebug
- name: Prevent pushing new screenshots if this is a fork - name: Prevent pushing new screenshots if this is a fork
id: checkfork id: checkfork_screenshots
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: |
@ -72,7 +103,7 @@ jobs:
# 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 - name: Run local tests
if: always() if: always()
run: ./gradlew testDemoDebug testProdDebug :lint:test run: ./gradlew testDemoDebug :lint:test
# Replace task exclusions with `-Pandroidx.baselineprofile.skipgeneration` when # Replace task exclusions with `-Pandroidx.baselineprofile.skipgeneration` when
# https://android-review.googlesource.com/c/platform/frameworks/support/+/2602790 landed in a # https://android-review.googlesource.com/c/platform/frameworks/support/+/2602790 landed in a
# release build # release build
@ -88,14 +119,14 @@ jobs:
-x collectProdNonMinifiedBenchmarkBaselineProfile -x collectProdNonMinifiedBenchmarkBaselineProfile
- name: Upload build outputs (APKs) - name: Upload build outputs (APKs)
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: APKs name: APKs
path: '**/build/outputs/apk/**/*.apk' path: '**/build/outputs/apk/**/*.apk'
- name: Upload test results (XML) - name: Upload test results (XML)
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: test-results name: test-results
path: '**/build/test-results/test*UnitTest/**.xml' path: '**/build/test-results/test*UnitTest/**.xml'
@ -105,7 +136,7 @@ jobs:
- name: Upload lint reports (HTML) - name: Upload lint reports (HTML)
if: always() if: always()
uses: actions/upload-artifact@v3 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'
@ -114,13 +145,20 @@ jobs:
run: ./gradlew :app:checkProdReleaseBadging run: ./gradlew :app:checkProdReleaseBadging
androidTest: androidTest:
runs-on: macOS-latest # enables hardware acceleration in the virtual machine runs-on: ubuntu-latest
timeout-minutes: 55 timeout-minutes: 55
strategy: strategy:
matrix: matrix:
api-level: [26, 30] api-level: [26, 30]
steps: steps:
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -128,7 +166,7 @@ jobs:
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: 17 java-version: 17
@ -151,7 +189,7 @@ jobs:
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: test-reports-${{ matrix.api-level }} name: test-reports-${{ matrix.api-level }}
path: '**/build/reports/androidTests' path: '**/build/reports/androidTests'

@ -7,10 +7,17 @@ on:
jobs: jobs:
build: build:
runs-on: macos-latest runs-on: ubuntu-latest
timeout-minutes: 120 timeout-minutes: 120
steps: steps:
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -21,7 +28,7 @@ jobs:
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: 'zulu'
java-version: 17 java-version: 17

@ -22,7 +22,7 @@ The app is currently in development. The `prodRelease` variant is [available on
**Now in Android** displays content from the **Now in Android** displays content from the
[Now in Android](https://developer.android.com/series/now-in-android) series. Users can browse for [Now in Android](https://developer.android.com/series/now-in-android) series. Users can browse for
links to recent videos, articles and other content. Users can also follow topics they are interested links to recent videos, articles and other content. Users can also follow topics they are interested
in. in, and be notified when new content is published which matches interests they are following.
## Screenshots ## Screenshots
@ -109,12 +109,42 @@ Examples:
manipulate the state of the `Test` repository and verify the resulting behavior, instead of manipulate the state of the `Test` repository and verify the resulting behavior, instead of
checking that specific repository methods were called. checking that specific repository methods were called.
## Screenshot tests To run the tests execute the following gradle tasks:
- `testDemoDebug` run all local tests against the `demoDebug` variant.
- `connectedDemoDebugAndroidTest` run all instrumented tests against the `demoDebug` variant.
**Now In Android** uses [Roborazzi](https://github.com/takahirom/roborazzi) to do screenshot tests **Note:** You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute
of certain screens and components. To run these tests, run the `verifyRoborazziDemoDebug` or tests against _all_ build variants which is both unecessary and will result in failures as only the
`recordRoborazziDemoDebug` tasks. Note that screenshots are recorded on CI, using Linux, and other `demoDebug` variant is supported. No other variants have any tests (although this might change in future).
platforms might generate slightly different images, making the tests fail.
## Screenshot tests
A screenshot test takes a screenshot of a screen or a UI component within the app, and compares it
with a previously recorded screenshot which is known to be rendered correctly.
For example, Now in Android has [screenshot tests](https://github.com/android/nowinandroid/blob/main/app/src/testDemoDebug/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt)
to verify that the navigation is displayed correctly on different screen sizes
([known correct screenshots](https://github.com/android/nowinandroid/tree/main/app/src/testDemoDebug/screenshots)).
Now In Android uses [Roborazzi](https://github.com/takahirom/roborazzi) to run screenshot tests
of certain screens and UI components. When working with screenshot tests the following gradle tasks are useful:
- `verifyRoborazziDemoDebug` run all screenshot tests, verifying the screenshots against the known
correct screenshots.
- `recordRoborazziDemoDebug` record new "known correct" screenshots. Use this command when you have
made changes to the UI and manually verified that they are rendered correctly. Screenshots will be
stored in `modulename/src/test/screenshots`.
- `compareRoborazziDemoDebug` create comparison images between failed tests and the known correct
images. These can also be found in `modulename/src/test/screenshots`.
**Note:** The known correct screenshots stored in this repository are recorded on CI using Linux. Other
platforms may (and probably will) generate slightly different images, making the screenshot tests fail.
When working on a non-Linux platform, a workaround to this is to run `recordRoborazziDemoDebug` on the
`main` branch before starting work. After making changes, `verifyRoborazziDemoDebug` will identify only
legitimate changes.
For more information about screenshot testing
[check out this talk](https://www.droidcon.com/2023/11/15/easy-screenshot-testing-with-compose/).
# UI # UI
The app was designed using [Material 3 guidelines](https://m3.material.io/). Learn more about the design process and The app was designed using [Material 3 guidelines](https://m3.material.io/). Learn more about the design process and

@ -65,7 +65,12 @@ android {
} }
dependencies { dependencies {
implementation(libs.androidx.activity.compose)
implementation(projects.core.designsystem) implementation(projects.core.designsystem)
implementation(projects.core.ui) implementation(projects.core.ui)
implementation(libs.androidx.activity.compose) }
dependencyGuard {
configuration("releaseRuntimeClasspath")
} }

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

@ -25,6 +25,7 @@ plugins {
alias(libs.plugins.nowinandroid.android.application.firebase) alias(libs.plugins.nowinandroid.android.application.firebase)
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)
} }
android { android {
@ -96,53 +97,41 @@ dependencies {
implementation(projects.core.data) implementation(projects.core.data)
implementation(projects.core.model) implementation(projects.core.model)
implementation(projects.core.analytics) implementation(projects.core.analytics)
implementation(projects.sync.work) implementation(projects.sync.work)
androidTestImplementation(projects.core.testing)
androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(projects.core.dataTest)
androidTestImplementation(projects.core.network)
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness)
androidTestImplementation(kotlin("test"))
debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(projects.uiTestHiltManifest)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.compose.runtime) implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.compose.material3.adaptive) {
this.isTransitive = false
}
implementation(libs.androidx.compose.material3.adaptive.navigation.suite) {
this.isTransitive = false
}
implementation(libs.androidx.compose.material3.windowSizeClass) implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.window.manager)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt) implementation(libs.coil.kt)
baselineProfile(project(":benchmarks")) debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(projects.uiTestHiltManifest)
kspTest(libs.hilt.compiler)
// Core functions
testImplementation(projects.core.testing)
testImplementation(projects.core.datastoreTest)
testImplementation(projects.core.dataTest) testImplementation(projects.core.dataTest)
testImplementation(projects.core.network) testImplementation(projects.core.testing)
testImplementation(libs.androidx.navigation.testing)
testImplementation(libs.accompanist.testharness) testImplementation(libs.accompanist.testharness)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.work.testing) testImplementation(libs.work.testing)
testImplementation(kotlin("test"))
kspTest(libs.hilt.compiler)
testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi)
androidTestImplementation(projects.core.testing)
androidTestImplementation(projects.core.dataTest)
androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness)
androidTestImplementation(libs.hilt.android.testing)
baselineProfile(projects.benchmarks)
} }
baselineProfile { baselineProfile {
@ -150,3 +139,7 @@ baselineProfile {
// Instead enable generation directly for the release build variant. // Instead enable generation directly for the release build variant.
automaticGenerationDuringBuild = false automaticGenerationDuringBuild = false
} }
dependencyGuard {
configuration("prodReleaseRuntimeClasspath")
}

@ -0,0 +1,205 @@
androidx.activity:activity-compose:1.8.0
androidx.activity:activity-ktx:1.8.0
androidx.activity:activity:1.8.0
androidx.annotation:annotation-experimental:1.3.1
androidx.annotation:annotation-jvm:1.7.0
androidx.annotation:annotation:1.7.0
androidx.appcompat:appcompat-resources:1.6.1
androidx.appcompat:appcompat:1.6.1
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.6.0
androidx.collection:collection-jvm:1.3.0
androidx.collection:collection-ktx:1.3.0
androidx.collection:collection:1.3.0
androidx.compose.animation:animation-android:1.5.4
androidx.compose.animation:animation-core-android:1.5.4
androidx.compose.animation:animation-core:1.5.4
androidx.compose.animation:animation:1.5.4
androidx.compose.foundation:foundation-android:1.5.4
androidx.compose.foundation:foundation-layout-android:1.5.4
androidx.compose.foundation:foundation-layout:1.5.4
androidx.compose.foundation:foundation:1.5.4
androidx.compose.material3:material3-window-size-class:1.1.2
androidx.compose.material3:material3:1.1.2
androidx.compose.material:material-icons-core-android:1.5.4
androidx.compose.material:material-icons-core:1.5.4
androidx.compose.material:material-icons-extended-android:1.5.4
androidx.compose.material:material-icons-extended:1.5.4
androidx.compose.material:material-ripple-android:1.5.4
androidx.compose.material:material-ripple:1.5.4
androidx.compose.runtime:runtime-android:1.5.4
androidx.compose.runtime:runtime-saveable-android:1.5.4
androidx.compose.runtime:runtime-saveable:1.5.4
androidx.compose.runtime:runtime:1.5.4
androidx.compose.ui:ui-android:1.5.4
androidx.compose.ui:ui-geometry-android:1.5.4
androidx.compose.ui:ui-geometry:1.5.4
androidx.compose.ui:ui-graphics-android:1.5.4
androidx.compose.ui:ui-graphics:1.5.4
androidx.compose.ui:ui-text-android:1.5.4
androidx.compose.ui:ui-text:1.5.4
androidx.compose.ui:ui-tooling-preview-android:1.5.4
androidx.compose.ui:ui-tooling-preview:1.5.4
androidx.compose.ui:ui-unit-android:1.5.4
androidx.compose.ui:ui-unit:1.5.4
androidx.compose.ui:ui-util-android:1.5.4
androidx.compose.ui:ui-util:1.5.4
androidx.compose.ui:ui:1.5.4
androidx.compose:compose-bom:2023.10.01
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.12.0
androidx.core:core-splashscreen:1.0.1
androidx.core:core:1.12.0
androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0
androidx.datastore:datastore-core:1.0.0
androidx.datastore:datastore-preferences-core:1.0.0
androidx.datastore:datastore-preferences:1.0.0
androidx.datastore:datastore:1.0.0
androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.0.0
androidx.emoji2:emoji2-views-helper:1.4.0
androidx.emoji2:emoji2:1.4.0
androidx.exifinterface:exifinterface:1.3.6
androidx.fragment:fragment:1.5.1
androidx.hilt:hilt-common:1.1.0
androidx.hilt:hilt-navigation-compose:1.0.0
androidx.hilt:hilt-navigation:1.0.0
androidx.hilt:hilt-work:1.1.0
androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.6.2
androidx.lifecycle:lifecycle-common:2.6.2
androidx.lifecycle:lifecycle-livedata-core:2.6.2
androidx.lifecycle:lifecycle-livedata:2.6.2
androidx.lifecycle:lifecycle-process:2.6.2
androidx.lifecycle:lifecycle-runtime-compose:2.6.2
androidx.lifecycle:lifecycle-runtime-ktx:2.6.2
androidx.lifecycle:lifecycle-runtime:2.6.2
androidx.lifecycle:lifecycle-service:2.6.2
androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.2
androidx.lifecycle:lifecycle-viewmodel:2.6.2
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
androidx.navigation:navigation-common-ktx:2.7.4
androidx.navigation:navigation-common:2.7.4
androidx.navigation:navigation-compose:2.7.4
androidx.navigation:navigation-runtime-ktx:2.7.4
androidx.navigation:navigation-runtime:2.7.4
androidx.print:print:1.0.0
androidx.privacysandbox.ads:ads-adservices-java:1.0.0-beta05
androidx.privacysandbox.ads:ads-adservices:1.0.0-beta05
androidx.profileinstaller:profileinstaller:1.3.1
androidx.resourceinspection:resourceinspection-annotation:1.0.1
androidx.room:room-common:2.6.1
androidx.room:room-ktx:2.6.1
androidx.room:room-runtime:2.6.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1
androidx.sqlite:sqlite-framework:2.4.0
androidx.sqlite:sqlite:2.4.0
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.1.0
androidx.tracing:tracing:1.1.0
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window:window:1.0.0
androidx.work:work-runtime-ktx:2.9.0
androidx.work:work-runtime:2.9.0
com.caverock:androidsvg-aar:1.4
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.accompanist:accompanist-permissions:0.32.0
com.google.android.datatransport:transport-api:3.0.0
com.google.android.datatransport:transport-backend-cct:3.1.9
com.google.android.datatransport:transport-runtime:3.1.9
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-basement:18.1.0
com.google.android.gms:play-services-cloud-messaging:17.0.1
com.google.android.gms:play-services-measurement-api:21.4.0
com.google.android.gms:play-services-measurement-base:21.4.0
com.google.android.gms:play-services-measurement-impl:21.4.0
com.google.android.gms:play-services-measurement-sdk-api:21.4.0
com.google.android.gms:play-services-measurement-sdk:21.4.0
com.google.android.gms:play-services-measurement:21.4.0
com.google.android.gms:play-services-oss-licenses:17.0.1
com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.0.2
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.50
com.google.dagger:dagger:2.50
com.google.dagger:hilt-android:2.50
com.google.dagger:hilt-core:2.50
com.google.errorprone:error_prone_annotations:2.11.0
com.google.firebase:firebase-abt:21.1.1
com.google.firebase:firebase-analytics-ktx:21.4.0
com.google.firebase:firebase-analytics:21.4.0
com.google.firebase:firebase-annotations:16.2.0
com.google.firebase:firebase-bom:32.4.0
com.google.firebase:firebase-common-ktx:20.4.2
com.google.firebase:firebase-common:20.4.2
com.google.firebase:firebase-components:17.1.5
com.google.firebase:firebase-config:21.5.0
com.google.firebase:firebase-crashlytics-ktx:18.5.0
com.google.firebase:firebase-crashlytics:18.5.0
com.google.firebase:firebase-datatransport:18.1.8
com.google.firebase:firebase-encoders-json:18.0.1
com.google.firebase:firebase-encoders-proto:16.0.0
com.google.firebase:firebase-encoders:17.0.0
com.google.firebase:firebase-iid-interop:17.1.0
com.google.firebase:firebase-installations-interop:17.1.1
com.google.firebase:firebase-installations:17.2.0
com.google.firebase:firebase-measurement-connector:19.0.0
com.google.firebase:firebase-messaging-ktx:23.3.0
com.google.firebase:firebase-messaging:23.3.0
com.google.firebase:firebase-perf-ktx:20.5.0
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:guava:31.1-android
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
com.google.j2objc:j2objc-annotations:1.3
com.google.protobuf:protobuf-javalite:3.24.4
com.google.protobuf:protobuf-kotlin-lite:3.24.4
com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0
com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.6.0
com.squareup.okio:okio:3.6.0
com.squareup.retrofit2:retrofit:2.9.0
io.coil-kt:coil-base:2.5.0
io.coil-kt:coil-compose-base:2.5.0
io.coil-kt:coil-compose:2.5.0
io.coil-kt:coil-svg:2.5.0
io.coil-kt:coil:2.5.0
io.github.aakira:napier-android:1.4.1
io.github.aakira:napier:1.4.1
javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.21
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.5.0
org.jetbrains.kotlinx:kotlinx-datetime:0.5.0
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.6.0
org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0
org.jetbrains:annotations:23.0.0

@ -105,9 +105,9 @@ application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml' application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
application: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml' application: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon='' launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon=''
uses-library-not-required:'android.ext.adservices'
uses-library-not-required:'androidx.window.extensions' uses-library-not-required:'androidx.window.extensions'
uses-library-not-required:'androidx.window.sidecar' uses-library-not-required:'androidx.window.sidecar'
uses-library-not-required:'android.ext.adservices'
feature-group: label='' feature-group: label=''
uses-feature: name='android.hardware.faketouch' uses-feature: name='android.hardware.faketouch'
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps' uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'

@ -20,6 +20,8 @@ import androidx.annotation.StringRes
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.hasText import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -49,7 +51,7 @@ import javax.inject.Inject
import kotlin.properties.ReadOnlyProperty 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.interests.R as FeatureInterestsR import com.google.samples.apps.nowinandroid.feature.search.R as FeatureSearchR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR
/** /**
@ -88,18 +90,18 @@ class NavigationTest {
lateinit var topicsRepository: TopicsRepository lateinit var topicsRepository: TopicsRepository
private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) =
ReadOnlyProperty<Any?, String> { _, _ -> activity.getString(resId) } ReadOnlyProperty<Any, String> { _, _ -> activity.getString(resId) }
// The strings used for matching in these tests // The strings used for matching in these tests
private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up) private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up)
private val forYou by composeTestRule.stringResource(FeatureForyouR.string.for_you) private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_title)
private val interests by composeTestRule.stringResource(FeatureInterestsR.string.interests) private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_interests)
private val sampleTopic = "Headlines" private val sampleTopic = "Headlines"
private val appName by composeTestRule.stringResource(R.string.app_name) private val appName by composeTestRule.stringResource(R.string.app_name)
private val saved by composeTestRule.stringResource(BookmarksR.string.saved) private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_title)
private val settings by composeTestRule.stringResource(SettingsR.string.top_app_bar_action_icon_description) private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_top_app_bar_action_icon_description)
private val brand by composeTestRule.stringResource(SettingsR.string.brand_android) private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text) private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_dismiss_dialog_button_text)
@Before @Before
fun setup() = hiltRule.inject() fun setup() = hiltRule.inject()
@ -164,7 +166,10 @@ class NavigationTest {
composeTestRule.apply { composeTestRule.apply {
// GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown. // GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown.
onNodeWithContentDescription(navigateUp).assertDoesNotExist() onNodeWithContentDescription(navigateUp).assertDoesNotExist()
// TODO: Add top level destinations here, see b/226357686.
onNodeWithText(saved).performClick()
onNodeWithContentDescription(navigateUp).assertDoesNotExist()
onNodeWithText(interests).performClick() onNodeWithText(interests).performClick()
onNodeWithContentDescription(navigateUp).assertDoesNotExist() onNodeWithContentDescription(navigateUp).assertDoesNotExist()
} }
@ -218,6 +223,14 @@ class NavigationTest {
onNodeWithText(saved).performClick() onNodeWithText(saved).performClick()
onNodeWithContentDescription(settings).performClick() onNodeWithContentDescription(settings).performClick()
onNodeWithText(ok).performClick() onNodeWithText(ok).performClick()
// Check that the saved screen is still visible and selected.
onNode(
hasText(saved) and
hasAnyAncestor(
hasTestTag("NiaBottomBar") or hasTestTag("NiaNavRail"),
),
).assertIsSelected()
} }
} }

@ -0,0 +1,268 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.google.accompanist.testharness.TestHarness
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import javax.inject.Inject
/**
* Tests that the navigation UI is rendered correctly on different screen sizes.
*/
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@HiltAndroidTest
class NavigationUiTest {
/**
* Manages the components' state and is used to perform injection on your test
*/
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
/**
* Create a temporary folder used to create a Data Store file. This guarantees that
* the file is removed in between each test, preventing a crash.
*/
@BindValue
@get:Rule(order = 1)
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
/**
* Grant [android.Manifest.permission.POST_NOTIFICATIONS] permission.
*/
@get:Rule(order = 2)
val postNotificationsPermission = GrantPostNotificationsPermissionRule()
/**
* Use a test activity to set the content on.
*/
@get:Rule(order = 3)
val composeTestRule = createAndroidComposeRule<HiltComponentActivity>()
val userNewsResourceRepository = CompositeUserNewsResourceRepository(
newsRepository = TestNewsRepository(),
userDataRepository = TestUserDataRepository(),
)
@Inject
lateinit var networkMonitor: NetworkMonitor
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun compactWidth_compactHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_compactHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 400.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_mediumHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_mediumHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 500.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun compactWidth_expandedHeight_showsNavigationBar() {
composeTestRule.setContent {
TestHarness(size = DpSize(400.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaBottomBar").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaNavRail").assertDoesNotExist()
}
@Test
fun mediumWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(610.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
@Test
fun expandedWidth_expandedHeight_showsNavigationRail() {
composeTestRule.setContent {
TestHarness(size = DpSize(900.dp, 1000.dp)) {
BoxWithConstraints {
NiaApp(
windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
}
}
composeTestRule.onNodeWithTag("NiaNavRail").assertIsDisplayed()
composeTestRule.onNodeWithTag("NiaBottomBar").assertDoesNotExist()
}
}

@ -16,6 +16,8 @@
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
@ -39,6 +41,7 @@ import kotlinx.coroutines.test.runTest
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
/** /**
@ -47,6 +50,7 @@ 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
@ -71,7 +75,7 @@ class NiaAppStateTest {
NiaAppState( NiaAppState(
navController = navController, navController = navController,
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSize = getCompactWindowSize(), windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
) )
@ -93,7 +97,7 @@ class NiaAppStateTest {
fun niaAppState_destinations() = runTest { fun niaAppState_destinations() = runTest {
composeTestRule.setContent { composeTestRule.setContent {
state = rememberNiaAppState( state = rememberNiaAppState(
windowSize = getCompactWindowSize(), windowSizeClass = getCompactWindowClass(),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
) )
@ -105,13 +109,61 @@ 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,
)
}
assertTrue(state.shouldShowBottomBar)
assertFalse(state.shouldShowNavRail)
}
@Test
fun niaAppState_showNavRail_medium() = runTest {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
assertTrue(state.shouldShowNavRail)
assertFalse(state.shouldShowBottomBar)
}
@Test
fun niaAppState_showNavRail_large() = runTest {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
)
}
assertTrue(state.shouldShowNavRail)
assertFalse(state.shouldShowBottomBar)
}
@Test @Test
fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent { composeTestRule.setContent {
state = NiaAppState( state = NiaAppState(
navController = NavHostController(LocalContext.current), navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope, coroutineScope = backgroundScope,
windowSize = DpSize(900.dp, 1200.dp), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
) )
@ -125,7 +177,7 @@ class NiaAppStateTest {
) )
} }
private fun getCompactWindowSize() = DpSize(500.dp, 300.dp) private fun getCompactWindowClass() = WindowSizeClass.calculateFromSize(DpSize(500.dp, 300.dp))
} }
@Composable @Composable

@ -15,6 +15,8 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<!-- Allow users to distinguish between build variants by having a different background color
for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->
<color name="ic_launcher_background_tint">#FFFFFF</color> <color name="ic_launcher_background_tint">#FFFFFF</color>
<color name="ic_launcher_foreground_tint">#FF006780</color> <color name="ic_launcher_foreground_tint">#FF006780</color>
</resources> </resources>

@ -15,6 +15,8 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<!-- Allow users to distinguish between build variants by having a different background color
for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->
<color name="ic_launcher_background_tint">#000000</color> <color name="ic_launcher_background_tint">#000000</color>
<color name="ic_launcher_foreground_tint">#FF006780</color> <color name="ic_launcher_foreground_tint">#FF006780</color>
</resources> </resources>

@ -15,6 +15,8 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<!-- Allow users to distinguish between build variants by having a different background color
for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->
<color name="ic_launcher_background_tint">#FFFFFF</color> <color name="ic_launcher_background_tint">#FFFFFF</color>
<color name="ic_launcher_foreground_tint">#FFA23F16</color> <color name="ic_launcher_foreground_tint">#FFA23F16</color>
</resources> </resources>

@ -15,6 +15,8 @@
limitations under the License. limitations under the License.
--> -->
<resources> <resources>
<!-- Allow users to distinguish between build variants by having a different background color
for the launcher icon. See https://github.com/android/nowinandroid/pull/989. -->
<color name="ic_launcher_background_tint">#000000</color> <color name="ic_launcher_background_tint">#000000</color>
<color name="ic_launcher_foreground_tint">#FFA23F16</color> <color name="ic_launcher_foreground_tint">#FFA23F16</color>
</resources> </resources>

@ -0,0 +1,70 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.util
import android.util.Log
import androidx.profileinstaller.ProfileVerifier
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].
*
* When delivering through Google Play, the baseline profile is compiled during installation.
* In this case you will see the correct state logged without any further action necessary.
* To verify baseline profile installation locally, you need to manually trigger baseline
* profile installation.
*
* For immediate compilation, call:
* ```bash
* adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target
* ```
* You can also trigger background optimizations:
* ```bash
* adb shell pm bg-dexopt-job
* ```
* Both jobs run asynchronously and might take some time complete.
*
* To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
* If you don't do either of these steps, you might only see the profile status reported as
* "enqueued for compilation" when running the sample locally.
*
* @see androidx.profileinstaller.ProfileVerifier.CompilationStatus.ResultCode
*/
class ProfileVerifierLogger @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
) {
companion object {
private const val TAG = "ProfileInstaller"
}
operator fun invoke() = scope.launch {
val status = ProfileVerifier.getCompilationStatusAsync().await()
Log.d(TAG, "Status code: ${status.profileInstallResultCode}")
Log.d(
TAG,
when {
status.isCompiledWithProfile -> "App compiled with profile"
status.hasProfileEnqueuedForCompilation() -> "Profile enqueued for compilation"
else -> "Profile not compiled nor enqueued"
},
)
}
}

@ -17,30 +17,25 @@
package com.google.samples.apps.nowinandroid package com.google.samples.apps.nowinandroid
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle 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.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.adaptive.currentWindowSize import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.metrics.performance.JankStats import androidx.metrics.performance.JankStats
import androidx.profileinstaller.ProfileVerifier
import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading
import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.MainActivityUiState.Success
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
@ -52,16 +47,14 @@ 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.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
private const val TAG = "MainActivity" private const val TAG = "MainActivity"
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -82,7 +75,6 @@ class MainActivity : ComponentActivity() {
val viewModel: MainActivityViewModel by viewModels() val viewModel: MainActivityViewModel by viewModels()
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen() val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -93,9 +85,7 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState viewModel.uiState
.onEach { .onEach { uiState = it }
uiState = it
}
.collect() .collect()
} }
} }
@ -142,10 +132,9 @@ class MainActivity : ComponentActivity() {
androidTheme = shouldUseAndroidTheme(uiState), androidTheme = shouldUseAndroidTheme(uiState),
disableDynamicTheming = shouldDisableDynamicTheming(uiState), disableDynamicTheming = shouldDisableDynamicTheming(uiState),
) { ) {
val windowSize = currentWindowSize()
NiaApp( NiaApp(
windowSize = windowSize.toDpSize(),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSizeClass = calculateWindowSizeClass(this),
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
) )
} }
@ -156,48 +145,12 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
lazyStats.get().isTrackingEnabled = true lazyStats.get().isTrackingEnabled = true
lifecycleScope.launch {
logCompilationStatus()
}
} }
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
lazyStats.get().isTrackingEnabled = false lazyStats.get().isTrackingEnabled = false
} }
/**
* Logs the app's Baseline Profile Compilation Status using [ProfileVerifier].
*/
private suspend fun logCompilationStatus() {
/*
When delivering through Google Play, the baseline profile is compiled during installation.
In this case you will see the correct state logged without any further action necessary.
To verify baseline profile installation locally, you need to manually trigger baseline
profile installation.
For immediate compilation, call:
`adb shell cmd package compile -f -m speed-profile com.example.macrobenchmark.target`
You can also trigger background optimizations:
`adb shell pm bg-dexopt-job`
Both jobs run asynchronously and might take some time complete.
To see quick turnaround of the ProfileVerifier, we recommend using `speed-profile`.
If you don't do either of these steps, you might only see the profile status reported as
"enqueued for compilation" when running the sample locally.
*/
withContext(Dispatchers.IO) {
val status = ProfileVerifier.getCompilationStatusAsync().await()
Log.d(TAG, "ProfileInstaller status code: ${status.profileInstallResultCode}")
Log.d(
TAG,
when {
status.isCompiledWithProfile -> "ProfileInstaller: is compiled with profile"
status.hasProfileEnqueuedForCompilation() ->
"ProfileInstaller: Enqueued for compilation"
else -> "Profile not compiled or enqueued"
},
)
}
}
} }
/** /**
@ -241,11 +194,6 @@ private fun shouldUseDarkTheme(
} }
} }
@Composable
private fun IntSize.toDpSize(): DpSize = with(LocalDensity.current) {
DpSize(width.toDp(), height.toDp())
}
/** /**
* The default light scrim, as defined by androidx and the platform: * The default light scrim, as defined by androidx and the platform:
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598 * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:activity/activity/src/main/java/androidx/activity/EdgeToEdge.kt;l=35-38;drc=27e7d52e8604a080133e8b842db10c89b4482598

@ -20,6 +20,7 @@ import android.app.Application
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
import com.google.samples.apps.nowinandroid.util.ProfileVerifierLogger
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@ -32,10 +33,14 @@ class NiaApplication : Application(), ImageLoaderFactory {
@Inject @Inject
lateinit var imageLoader: Provider<ImageLoader> lateinit var imageLoader: Provider<ImageLoader>
@Inject
lateinit var profileVerifierLogger: ProfileVerifierLogger
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// 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()
} }
override fun newImageLoader(): ImageLoader = imageLoader.get() override fun newImageLoader(): ImageLoader = imageLoader.get()

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

@ -24,13 +24,13 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
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.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.FOR_YOU_ROUTE
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen
import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
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.TOPIC_NAVIGATION_ROUTE
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.topicNavigationRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen 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
@ -47,7 +47,7 @@ fun NiaNavHost(
appState: NiaAppState, appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean, onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
startDestination: String = forYouNavigationRoute, startDestination: String = FOR_YOU_ROUTE,
) { ) {
val navController = appState.navController val navController = appState.navController
NavHost( NavHost(
@ -70,7 +70,7 @@ fun NiaNavHost(
val nestedNavController = rememberNavController() val nestedNavController = rememberNavController()
NavHost( NavHost(
navController = nestedNavController, navController = nestedNavController,
startDestination = topicNavigationRoute, startDestination = TOPIC_NAVIGATION_ROUTE,
) { ) {
topicScreen(onTopicClick = nestedNavController::navigateToTopic) topicScreen(onTopicClick = nestedNavController::navigateToTopic)
} }

@ -21,7 +21,7 @@ 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.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.interests.R as interestsR 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. Each of these destinations
@ -37,19 +37,19 @@ enum class TopLevelDestination(
FOR_YOU( FOR_YOU(
selectedIcon = NiaIcons.Upcoming, selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder, unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.for_you, iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name, titleTextId = R.string.app_name,
), ),
BOOKMARKS( BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks, selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder, unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.saved, iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.saved, titleTextId = bookmarksR.string.feature_bookmarks_title,
), ),
INTERESTS( INTERESTS(
selectedIcon = NiaIcons.Grid3x3, selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3, unselectedIcon = NiaIcons.Grid3x3,
iconTextId = interestsR.string.interests, iconTextId = searchR.string.feature_search_interests,
titleTextId = interestsR.string.interests, titleTextId = searchR.string.feature_search_interests,
), ),
} }

@ -16,10 +16,18 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material3.BadgedBox import androidx.compose.foundation.layout.safeDrawingPadding
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
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -31,9 +39,7 @@ 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.navigationsuite.ExperimentalMaterial3AdaptiveNavigationSuiteApi import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
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
@ -41,10 +47,17 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
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.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
@ -53,6 +66,10 @@ 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.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.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
@ -64,24 +81,23 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
@OptIn( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalMaterial3AdaptiveNavigationSuiteApi::class, ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class,
) )
@Composable @Composable
fun NiaApp( fun NiaApp(
windowSize: DpSize, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
appState: NiaAppState = rememberNiaAppState( appState: NiaAppState = rememberNiaAppState(
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
windowSize = windowSize, windowSizeClass = windowSizeClass,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
), ),
) { ) {
val shouldShowGradientBackground = val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
var showSettingsDialog by rememberSaveable { var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
mutableStateOf(false)
}
NiaBackground { NiaBackground {
NiaGradientBackground( NiaGradientBackground(
@ -113,59 +129,63 @@ fun NiaApp(
} }
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle()
val currentDestination = appState.currentDestination
NavigationSuiteScaffold( Scaffold(
layoutType = appState.navigationSuiteType, modifier = Modifier.semantics {
containerColor = Color.Transparent, testTagsAsResourceId = true
navigationSuiteColors = NavigationSuiteDefaults.colors(
navigationRailContainerColor = Color.Transparent,
navigationDrawerContainerColor = Color.Transparent,
),
navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination ->
val isSelected =
currentDestination.isTopLevelDestinationInHierarchy(destination)
val isUnread = unreadDestinations.contains(destination)
item(
selected = isSelected,
icon = {
BadgedBox(
badge = {
if (isUnread) {
Badge()
}
},
) {
Icon(
imageVector = if (isSelected) {
destination.selectedIcon
} else {
destination.unselectedIcon
}, },
contentDescription = null, containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
if (appState.shouldShowBottomBar) {
NiaBottomBar(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier.testTag("NiaBottomBar"),
) )
} }
}, },
label = { Text(stringResource(destination.iconTextId)) }, ) { padding ->
onClick = { appState.navigateToTopLevelDestination(destination) }, Row(
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal,
),
),
) {
if (appState.shouldShowNavRail) {
NiaNavRail(
destinations = appState.topLevelDestinations,
destinationsWithUnreadResources = unreadDestinations,
onNavigateToDestination = appState::navigateToTopLevelDestination,
currentDestination = appState.currentDestination,
modifier = Modifier
.testTag("NiaNavRail")
.safeDrawingPadding(),
) )
} }
},
) { Column(Modifier.fillMaxSize()) {
Scaffold( // Show the top app bar on top level destinations.
topBar = {
val destination = appState.currentTopLevelDestination val destination = appState.currentTopLevelDestination
if (destination != null) { if (destination != null) {
NiaTopAppBar( NiaTopAppBar(
titleRes = destination.titleTextId, titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search, navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource( navigationIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_navigation_icon_description, id = settingsR.string.feature_settings_top_app_bar_navigation_icon_description,
), ),
actionIcon = NiaIcons.Settings, actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource( actionIconContentDescription = stringResource(
id = settingsR.string.top_app_bar_action_icon_description, id = settingsR.string.feature_settings_top_app_bar_action_icon_description,
), ),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@ -174,26 +194,110 @@ fun NiaApp(
onNavigationClick = { appState.navigateToSearch() }, onNavigationClick = { appState.navigateToSearch() },
) )
} }
},
contentWindowInsets = WindowInsets(0, 0, 0, 0), NiaNavHost(appState = appState, onShowSnackbar = { message, action ->
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
) { padding ->
NiaNavHost(
appState = appState,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
message = message, message = message,
actionLabel = action, actionLabel = action,
duration = Short, duration = Short,
) == ActionPerformed ) == ActionPerformed
})
}
// TODO: We may want to add padding or spacer when the snackbar is shown so that
// content doesn't display behind it.
}
}
}
}
}
@Composable
private fun NiaNavRail(
destinations: List<TopLevelDestination>,
destinationsWithUnreadResources: Set<TopLevelDestination>,
onNavigateToDestination: (TopLevelDestination) -> Unit,
currentDestination: NavDestination?,
modifier: Modifier = Modifier,
) {
NiaNavigationRail(modifier = modifier) {
destinations.forEach { destination ->
val selected = currentDestination.isTopLevelDestinationInHierarchy(destination)
val hasUnread = destinationsWithUnreadResources.contains(destination)
NiaNavigationRailItem(
selected = selected,
onClick = { onNavigateToDestination(destination) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
contentDescription = null,
)
}, },
modifier = Modifier.padding(padding), 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 =
composed {
val tertiaryColor = MaterialTheme.colorScheme.tertiary
drawWithContent {
drawContent()
drawCircle(
tertiaryColor,
radius = 5.dp.toPx(),
// This is based on the dimensions of the NavigationBar's "indicator pill";
// however, its parameters are private, so we must depend on them implicitly
// (NavigationBarTokens.ActiveIndicatorWidth = 64.dp)
center = center + Offset(
64.dp.toPx() * .45f,
32.dp.toPx() * -.45f - 6.dp.toPx(),
),
)
} }
} }

@ -16,14 +16,12 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.adaptive.navigationsuite.ExperimentalMaterial3AdaptiveNavigationSuiteApi import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType 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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
@ -35,11 +33,11 @@ import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
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.bookmarksRoute 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.forYouNavigationRoute 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.interestsRoute import com.google.samples.apps.nowinandroid.feature.interests.navigation.INTERESTS_ROUTE
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph
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 +53,7 @@ import kotlinx.coroutines.flow.stateIn
@Composable @Composable
fun rememberNiaAppState( fun rememberNiaAppState(
windowSize: DpSize, windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
coroutineScope: CoroutineScope = rememberCoroutineScope(), coroutineScope: CoroutineScope = rememberCoroutineScope(),
@ -65,14 +63,14 @@ fun rememberNiaAppState(
return remember( return remember(
navController, navController,
coroutineScope, coroutineScope,
windowSize, windowSizeClass,
networkMonitor, networkMonitor,
userNewsResourceRepository, userNewsResourceRepository,
) { ) {
NiaAppState( NiaAppState(
navController, navController,
coroutineScope, coroutineScope,
windowSize, windowSizeClass,
networkMonitor, networkMonitor,
userNewsResourceRepository, userNewsResourceRepository,
) )
@ -83,7 +81,7 @@ fun rememberNiaAppState(
class NiaAppState( class NiaAppState(
val navController: NavHostController, val navController: NavHostController,
val coroutineScope: CoroutineScope, val coroutineScope: CoroutineScope,
private val windowSize: DpSize, val windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor, networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository, userNewsResourceRepository: UserNewsResourceRepository,
) { ) {
@ -93,12 +91,18 @@ class NiaAppState(
val currentTopLevelDestination: TopLevelDestination? val currentTopLevelDestination: TopLevelDestination?
@Composable get() = when (currentDestination?.route) { @Composable get() = when (currentDestination?.route) {
forYouNavigationRoute -> FOR_YOU FOR_YOU_ROUTE -> FOR_YOU
bookmarksRoute -> BOOKMARKS BOOKMARKS_ROUTE -> BOOKMARKS
interestsRoute -> INTERESTS INTERESTS_ROUTE -> INTERESTS
else -> null 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)
.stateIn( .stateIn(
@ -111,7 +115,7 @@ class NiaAppState(
* Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the * Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the
* route. * route.
*/ */
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.values().asList() val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
/** /**
* The top level destinations that have unread news resources. * The top level destinations that have unread news resources.
@ -129,26 +133,6 @@ class NiaAppState(
initialValue = emptySet(), initialValue = emptySet(),
) )
/**
* Per <a href="https://m3.material.io/components/navigation-drawer/guidelines">Material Design 3 guidelines</a>,
* the selection of the appropriate navigation component should be contingent on the available
* window size:
* - Bottom Bar for compact window sizes (below 600dp)
* - Navigation Rail for medium and expanded window sizes up to 1240dp (between 600dp and 1240dp)
* - Navigation Drawer to window size above 1240dp
*/
@OptIn(ExperimentalMaterial3AdaptiveNavigationSuiteApi::class)
val navigationSuiteType: NavigationSuiteType
@Composable get() {
return if (windowSize.width > 1240.dp) {
NavigationSuiteType.NavigationDrawer
} else if (windowSize.width >= 600.dp) {
NavigationSuiteType.NavigationRail
} else {
NavigationSuiteType.NavigationBar
}
}
/** /**
* UI logic for navigating to a top level destination in the app. Top level destinations have * UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you * only one copy of the destination of the back stack, and save and restore state whenever you
@ -180,9 +164,7 @@ class NiaAppState(
} }
} }
fun navigateToSearch() { fun navigateToSearch() = navController.navigateToSearch()
navController.navigateToSearch()
}
} }
/** /**

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

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

@ -17,6 +17,9 @@
package com.google.samples.apps.nowinandroid.ui package com.google.samples.apps.nowinandroid.ui
import android.util.Log import android.util.Log
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -57,11 +60,12 @@ 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.
// This allows enough room to render the content under test without clipping or scaling. // This allows enough room to render the content under test without clipping or scaling.
@Config(application = HiltTestApplication::class, qualifiers = "w1300dp-h1000dp-480dpi", sdk = [33]) @Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
@HiltAndroidTest @HiltAndroidTest
class NiaAppScreenSizesScreenshotTests { class NiaAppScreenSizesScreenshotTests {
@ -134,16 +138,19 @@ class NiaAppScreenSizesScreenshotTests {
CompositionLocalProvider( CompositionLocalProvider(
LocalInspectionMode provides true, LocalInspectionMode provides true,
) { ) {
val windowSize = DpSize(width, height) TestHarness(size = DpSize(width, height)) {
TestHarness(size = windowSize) { BoxWithConstraints {
NiaApp( NiaApp(
windowSize = windowSize, windowSizeClass = WindowSizeClass.calculateFromSize(
DpSize(maxWidth, maxHeight),
),
networkMonitor = networkMonitor, networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository, userNewsResourceRepository = userNewsResourceRepository,
) )
} }
} }
} }
}
composeTestRule.onRoot() composeTestRule.onRoot()
.captureRoboImage( .captureRoboImage(
@ -232,31 +239,4 @@ class NiaAppScreenSizesScreenshotTests {
"expandedWidth_expandedHeight_showsNavigationRail", "expandedWidth_expandedHeight_showsNavigationRail",
) )
} }
@Test
fun largeScreenWidth_compactHeight_showsPermanentDrawer() {
testNiaAppScreenshotWithSize(
1300.dp,
400.dp,
"largeScreenWidth_compactHeight_showsPermanentDrawer",
)
}
@Test
fun largeScreenWidth_mediumHeight_showsPermanentDrawer() {
testNiaAppScreenshotWithSize(
1300.dp,
500.dp,
"largeScreenWidth_mediumHeight_showsPermanentDrawer",
)
}
@Test
fun largeScreenWidth_expandedHeight_showsPermanentDrawer() {
testNiaAppScreenshotWithSize(
1300.dp,
1000.dp,
"largeScreenWidth_expandedHeight_showsPermanentDrawer",
)
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

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

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

@ -45,7 +45,7 @@ class ScrollTopicListPowerMetricsBenchmark {
@get:Rule @get:Rule
val benchmarkRule = MacrobenchmarkRule() val benchmarkRule = MacrobenchmarkRule()
private val categories = PowerCategory.values() private val categories = PowerCategory.entries
.associateWith { PowerCategoryDisplayLevel.TOTAL } .associateWith { PowerCategoryDisplayLevel.TOTAL }
@Test @Test

@ -60,7 +60,8 @@ class StartupBenchmark {
packageName = PACKAGE_NAME, packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()), metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode, compilationMode = compilationMode,
iterations = 20, // More iterations result in higher statistical significance. // More iterations result in higher statistical significance.
iterations = 20,
startupMode = COLD, startupMode = COLD,
setupBlock = { setupBlock = {
pressHome() pressHome()

@ -41,6 +41,15 @@ dependencies {
compileOnly(libs.firebase.performance.gradlePlugin) compileOnly(libs.firebase.performance.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room.gradlePlugin)
implementation(libs.truth)
}
tasks {
validatePlugins {
enableStricterValidation = true
failOnWarning = true
}
} }
gradlePlugin { gradlePlugin {

@ -24,8 +24,6 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
pluginManager.apply("com.android.application") pluginManager.apply("com.android.application")
// Screenshot Tests
pluginManager.apply("io.github.takahirom.roborazzi")
val extension = extensions.getByType<ApplicationExtension>() val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension) configureAndroidCompose(extension)

@ -15,10 +15,10 @@
*/ */
import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
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.google.samples.apps.nowinandroid.configureBadgingTasks import com.google.samples.apps.nowinandroid.configureBadgingTasks
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin import org.gradle.api.Plugin
@ -33,6 +33,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
apply("com.android.application") apply("com.android.application")
apply("org.jetbrains.kotlin.android") apply("org.jetbrains.kotlin.android")
apply("nowinandroid.android.lint") apply("nowinandroid.android.lint")
apply("com.dropbox.dependency-guard")
} }
extensions.configure<ApplicationExtension> { extensions.configure<ApplicationExtension> {

@ -21,7 +21,6 @@ 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.kotlin
class AndroidFeatureConventionPlugin : Plugin<Project> { class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
@ -39,27 +38,12 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
} }
dependencies { dependencies {
add("implementation", project(":core:model"))
add("implementation", project(":core:ui")) add("implementation", project(":core:ui"))
add("implementation", project(":core:designsystem")) add("implementation", project(":core:designsystem"))
add("implementation", project(":core:data"))
add("implementation", project(":core:common"))
add("implementation", project(":core:domain"))
add("implementation", project(":core:analytics"))
add("testImplementation", kotlin("test"))
add("testImplementation", project(":core:testing"))
add("androidTestImplementation", kotlin("test"))
add("androidTestImplementation", project(":core:testing"))
add("implementation", libs.findLibrary("coil.kt").get())
add("implementation", libs.findLibrary("coil.kt.compose").get())
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("kotlinx.coroutines.android").get())
} }
} }
} }

@ -30,8 +30,6 @@ class AndroidHiltConventionPlugin : Plugin<Project> {
dependencies { dependencies {
"implementation"(libs.findLibrary("hilt.android").get()) "implementation"(libs.findLibrary("hilt.android").get())
"ksp"(libs.findLibrary("hilt.compiler").get()) "ksp"(libs.findLibrary("hilt.compiler").get())
"kspAndroidTest"(libs.findLibrary("hilt.compiler").get())
"kspTest"(libs.findLibrary("hilt.compiler").get())
} }
} }

@ -26,8 +26,6 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
pluginManager.apply("com.android.library") pluginManager.apply("com.android.library")
// Screenshot Tests
pluginManager.apply("io.github.takahirom.roborazzi")
val extension = extensions.getByType<LibraryExtension>() val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(extension) configureAndroidCompose(extension)

@ -41,6 +41,9 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
defaultConfig.targetSdk = 34 defaultConfig.targetSdk = 34
configureFlavors(this) configureFlavors(this)
configureGradleManagedDevices(this) configureGradleManagedDevices(this)
// The resource prefix is derived from the module name,
// so resources inside ":core:module1" must be prefixed with "core_module1_"
resourcePrefix = path.split("""\W""".toRegex()).drop(1).distinct().joinToString(separator = "_").lowercase() + "_"
} }
extensions.configure<LibraryAndroidComponentsExtension> { extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this) configurePrintApksTask(this)
@ -48,9 +51,6 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
} }
dependencies { dependencies {
add("testImplementation", kotlin("test")) add("testImplementation", kotlin("test"))
add("testImplementation", project(":core:testing"))
add("androidTestImplementation", kotlin("test"))
add("androidTestImplementation", project(":core:testing"))
} }
} }
} }

@ -14,29 +14,25 @@
* limitations under the License. * limitations under the License.
*/ */
import com.google.devtools.ksp.gradle.KspExtension import androidx.room.gradle.RoomExtension
import com.google.samples.apps.nowinandroid.libs 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.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
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.process.CommandLineArgumentProvider
import java.io.File
class AndroidRoomConventionPlugin : Plugin<Project> { class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
pluginManager.apply("androidx.room")
pluginManager.apply("com.google.devtools.ksp") pluginManager.apply("com.google.devtools.ksp")
extensions.configure<KspExtension> { extensions.configure<RoomExtension> {
// The schemas directory contains a schema file for each version of the Room database. // The schemas directory contains a schema file for each version of the Room database.
// This is required to enable Room auto migrations. // This is required to enable Room auto migrations.
// See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration. // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration.
arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) schemaDirectory("$projectDir/schemas")
} }
dependencies { dependencies {
@ -46,16 +42,4 @@ class AndroidRoomConventionPlugin : Plugin<Project> {
} }
} }
} }
/**
* https://issuetracker.google.com/issues/132245929
* [Export schemas](https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas)
*/
class RoomSchemaArgProvider(
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
val schemaDir: File,
) : CommandLineArgumentProvider {
override fun asArguments() = listOf("room.schemaLocation=${schemaDir.path}")
}
} }

@ -41,11 +41,6 @@ internal fun Project.configureAndroidCompose(
val bom = libs.findLibrary("androidx-compose-bom").get() val bom = libs.findLibrary("androidx-compose-bom").get()
add("implementation", platform(bom)) add("implementation", platform(bom))
add("androidTestImplementation", platform(bom)) add("androidTestImplementation", platform(bom))
// Add ComponentActivity to debug manifest
add("debugImplementation", libs.findLibrary("androidx.compose.ui.testManifest").get())
// Screenshot Tests on JVM
add("testImplementation", libs.findLibrary("robolectric").get())
add("testImplementation", libs.findLibrary("roborazzi").get())
} }
testOptions { testOptions {

@ -20,34 +20,39 @@ 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.android.SdkConstants
import com.google.common.truth.Truth.assertWithMessage
import org.gradle.api.DefaultTask import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Copy import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.Input import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.configurationcache.extensions.capitalized import org.gradle.configurationcache.extensions.capitalized
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.nio.file.Files
import javax.inject.Inject import javax.inject.Inject
@CacheableTask
abstract class GenerateBadgingTask : DefaultTask() { abstract class GenerateBadgingTask : DefaultTask() {
@get:OutputFile @get:OutputFile
abstract val badging: RegularFileProperty abstract val badging: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile @get:InputFile
abstract val apk: RegularFileProperty abstract val apk: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile @get:InputFile
abstract val aapt2Executable: RegularFileProperty abstract val aapt2Executable: RegularFileProperty
@ -68,6 +73,7 @@ abstract class GenerateBadgingTask : DefaultTask() {
} }
} }
@CacheableTask
abstract class CheckBadgingTask : DefaultTask() { abstract class CheckBadgingTask : DefaultTask() {
// In order for the task to be up-to-date when the inputs have not changed, // In order for the task to be up-to-date when the inputs have not changed,
@ -76,9 +82,11 @@ abstract class CheckBadgingTask : DefaultTask() {
@get:OutputDirectory @get:OutputDirectory
abstract val output: DirectoryProperty abstract val output: DirectoryProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile @get:InputFile
abstract val goldenBadging: RegularFileProperty abstract val goldenBadging: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile @get:InputFile
abstract val generatedBadging: RegularFileProperty abstract val generatedBadging: RegularFileProperty
@ -89,17 +97,12 @@ abstract class CheckBadgingTask : DefaultTask() {
@TaskAction @TaskAction
fun taskAction() { fun taskAction() {
if ( assertWithMessage(
Files.mismatch(
goldenBadging.get().asFile.toPath(),
generatedBadging.get().asFile.toPath(),
) != -1L
) {
throw GradleException(
"Generated badging is different from golden badging! " + "Generated badging is different from golden badging! " +
"If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}", "If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}",
) )
} .that(generatedBadging.get().asFile.readText())
.isEqualTo(goldenBadging.get().asFile.readText())
} }
} }

@ -83,10 +83,8 @@ private fun Project.configureKotlin() {
val warningsAsErrors: String? by project val warningsAsErrors: String? by project
allWarningsAsErrors = warningsAsErrors.toBoolean() allWarningsAsErrors = warningsAsErrors.toBoolean()
freeCompilerArgs = freeCompilerArgs + listOf( freeCompilerArgs = freeCompilerArgs + listOf(
"-opt-in=kotlin.RequiresOptIn",
// Enable experimental coroutines APIs, including Flow // Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlinx.coroutines.FlowPreview",
) )
} }
} }

@ -30,7 +30,10 @@ import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File import java.io.File
internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtension<*, *, *>) { internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtension<*, *, *>) {
@ -62,10 +65,14 @@ internal fun Project.configurePrintApksTask(extension: AndroidComponentsExtensio
} }
} }
@DisableCachingByDefault(because = "Prints output")
internal abstract class PrintApkLocationTask : DefaultTask() { internal abstract class PrintApkLocationTask : DefaultTask() {
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputDirectory @get:InputDirectory
abstract val apkFolder: DirectoryProperty abstract val apkFolder: DirectoryProperty
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:InputFiles @get:InputFiles
abstract val sources: ListProperty<Directory> abstract val sources: ListProperty<Directory>
@ -79,14 +86,12 @@ internal abstract class PrintApkLocationTask : DefaultTask() {
fun taskAction() { fun taskAction() {
val hasFiles = sources.orNull?.any { directory -> val hasFiles = sources.orNull?.any { directory ->
directory.asFileTree.files.any { directory.asFileTree.files.any {
it.isFile && it.parentFile.path.contains("build${File.separator}generated").not() it.isFile && "build${File.separator}generated" !in it.parentFile.path
} }
} ?: throw RuntimeException("Cannot check androidTest sources") } ?: throw RuntimeException("Cannot check androidTest sources")
// Don't print APK location if there are no androidTest source files // Don't print APK location if there are no androidTest source files
if (!hasFiles) { if (!hasFiles) return
return
}
val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get()) val builtArtifacts = builtArtifactsLoader.get().load(apkFolder.get())
?: throw RuntimeException("Cannot load APKs") ?: throw RuntimeException("Cannot load APKs")

@ -32,10 +32,12 @@ buildscript {
// Lists all plugins used throughout the project without applying them. // Lists all plugins used throughout the project without applying them.
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.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.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.firebase.crashlytics) apply false alias(libs.plugins.firebase.crashlytics) apply false
alias(libs.plugins.firebase.perf) apply false alias(libs.plugins.firebase.perf) apply false
alias(libs.plugins.gms) apply false alias(libs.plugins.gms) apply false
@ -43,4 +45,5 @@ plugins {
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.roborazzi) apply false alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.secrets) apply false alias(libs.plugins.secrets) apply false
alias(libs.plugins.room) apply false
} }

@ -24,9 +24,8 @@ android {
} }
dependencies { dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.core.ktx)
implementation(libs.firebase.analytics) prodImplementation(platform(libs.firebase.bom))
implementation(libs.kotlinx.coroutines.android) prodImplementation(libs.firebase.analytics)
} }

@ -23,7 +23,7 @@ import dagger.hilt.components.SingletonComponent
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class AnalyticsModule { internal abstract class AnalyticsModule {
@Binds @Binds
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: StubAnalyticsHelper): AnalyticsHelper abstract fun bindsAnalyticsHelper(analyticsHelperImpl: StubAnalyticsHelper): AnalyticsHelper
} }

@ -27,7 +27,7 @@ private const val TAG = "StubAnalyticsHelper"
* analytics events should be sent to a backend. * analytics events should be sent to a backend.
*/ */
@Singleton @Singleton
class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper { internal class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper {
override fun logEvent(event: AnalyticsEvent) { override fun logEvent(event: AnalyticsEvent) {
Log.d(TAG, "Received analytics event: $event") Log.d(TAG, "Received analytics event: $event")
} }

@ -28,13 +28,15 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class AnalyticsModule { internal abstract class AnalyticsModule {
@Binds @Binds
abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper
companion object { companion object {
@Provides @Provides
@Singleton @Singleton
fun provideFirebaseAnalytics(): FirebaseAnalytics { return Firebase.analytics } fun provideFirebaseAnalytics(): FirebaseAnalytics {
return Firebase.analytics
}
} }
} }

@ -23,7 +23,7 @@ import javax.inject.Inject
/** /**
* Implementation of `AnalyticsHelper` which logs events to a Firebase backend. * Implementation of `AnalyticsHelper` which logs events to a Firebase backend.
*/ */
class FirebaseAnalyticsHelper @Inject constructor( internal class FirebaseAnalyticsHelper @Inject constructor(
private val firebaseAnalytics: FirebaseAnalytics, private val firebaseAnalytics: FirebaseAnalytics,
) : AnalyticsHelper { ) : AnalyticsHelper {

@ -24,6 +24,6 @@ android {
} }
dependencies { dependencies {
implementation(libs.kotlinx.coroutines.android) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(projects.core.testing) testImplementation(libs.turbine)
} }

@ -34,7 +34,7 @@ annotation class ApplicationScope
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object CoroutineScopesModule { internal object CoroutineScopesModule {
@Provides @Provides
@Singleton @Singleton
@ApplicationScope @ApplicationScope

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

@ -24,6 +24,6 @@ android {
dependencies { dependencies {
api(projects.core.data) api(projects.core.data)
implementation(projects.core.testing)
implementation(projects.core.common) implementation(libs.hilt.android.testing)
} }

@ -31,18 +31,16 @@ android {
} }
dependencies { dependencies {
api(projects.core.common)
api(projects.core.database)
api(projects.core.datastore)
api(projects.core.network)
implementation(projects.core.analytics) implementation(projects.core.analytics)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.model)
implementation(projects.core.network)
implementation(projects.core.notifications) implementation(projects.core.notifications)
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotlinx.serialization.json)
testImplementation(projects.core.datastoreTest) testImplementation(projects.core.datastoreTest)
testImplementation(projects.core.testing) testImplementation(projects.core.testing)
} }

@ -35,35 +35,35 @@ import dagger.hilt.components.SingletonComponent
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface DataModule { abstract class DataModule {
@Binds @Binds
fun bindsTopicRepository( internal abstract fun bindsTopicRepository(
topicsRepository: OfflineFirstTopicsRepository, topicsRepository: OfflineFirstTopicsRepository,
): TopicsRepository ): TopicsRepository
@Binds @Binds
fun bindsNewsResourceRepository( internal abstract fun bindsNewsResourceRepository(
newsRepository: OfflineFirstNewsRepository, newsRepository: OfflineFirstNewsRepository,
): NewsRepository ): NewsRepository
@Binds @Binds
fun bindsUserDataRepository( internal abstract fun bindsUserDataRepository(
userDataRepository: OfflineFirstUserDataRepository, userDataRepository: OfflineFirstUserDataRepository,
): UserDataRepository ): UserDataRepository
@Binds @Binds
fun bindsRecentSearchRepository( internal abstract fun bindsRecentSearchRepository(
recentSearchRepository: DefaultRecentSearchRepository, recentSearchRepository: DefaultRecentSearchRepository,
): RecentSearchRepository ): RecentSearchRepository
@Binds @Binds
fun bindsSearchContentsRepository( internal abstract fun bindsSearchContentsRepository(
searchContentsRepository: DefaultSearchContentsRepository, searchContentsRepository: DefaultSearchContentsRepository,
): SearchContentsRepository ): SearchContentsRepository
@Binds @Binds
fun bindsNetworkMonitor( internal abstract fun bindsNetworkMonitor(
networkMonitor: ConnectivityManagerNetworkMonitor, networkMonitor: ConnectivityManagerNetworkMonitor,
): NetworkMonitor ): NetworkMonitor
} }

@ -25,7 +25,7 @@ import dagger.hilt.components.SingletonComponent
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface UserNewsResourceRepositoryModule { internal interface UserNewsResourceRepositoryModule {
@Binds @Binds
fun bindsUserNewsResourceRepository( fun bindsUserNewsResourceRepository(
userDataRepository: CompositeUserNewsResourceRepository, userDataRepository: CompositeUserNewsResourceRepository,

@ -20,7 +20,7 @@ import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) { internal fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) {
val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved" val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved"
val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id" val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id"
logEvent( logEvent(
@ -33,7 +33,7 @@ fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBoo
) )
} }
fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: Boolean) { internal fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: Boolean) {
val eventType = if (isFollowed) "topic_followed" else "topic_unfollowed" val eventType = if (isFollowed) "topic_followed" else "topic_unfollowed"
val paramKey = if (isFollowed) "followed_topic_id" else "unfollowed_topic_id" val paramKey = if (isFollowed) "followed_topic_id" else "unfollowed_topic_id"
logEvent( logEvent(
@ -46,7 +46,7 @@ fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: B
) )
} }
fun AnalyticsHelper.logThemeChanged(themeName: String) = internal fun AnalyticsHelper.logThemeChanged(themeName: String) =
logEvent( logEvent(
AnalyticsEvent( AnalyticsEvent(
type = "theme_changed", type = "theme_changed",
@ -56,7 +56,7 @@ fun AnalyticsHelper.logThemeChanged(themeName: String) =
), ),
) )
fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) = internal fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =
logEvent( logEvent(
AnalyticsEvent( AnalyticsEvent(
type = "dark_theme_config_changed", type = "dark_theme_config_changed",
@ -66,7 +66,7 @@ fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =
), ),
) )
fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) = internal fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =
logEvent( logEvent(
AnalyticsEvent( AnalyticsEvent(
type = "dynamic_color_preference_changed", type = "dynamic_color_preference_changed",
@ -76,7 +76,7 @@ fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =
), ),
) )
fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) { internal fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) {
val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset" val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset"
logEvent( logEvent(
AnalyticsEvent(type = eventType), AnalyticsEvent(type = eventType),

@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import javax.inject.Inject import javax.inject.Inject
class DefaultRecentSearchRepository @Inject constructor( internal class DefaultRecentSearchRepository @Inject constructor(
private val recentSearchQueryDao: RecentSearchQueryDao, private val recentSearchQueryDao: RecentSearchQueryDao,
) : RecentSearchRepository { ) : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { override suspend fun insertOrReplaceRecentSearch(searchQuery: String) {
@ -39,9 +39,7 @@ class DefaultRecentSearchRepository @Inject constructor(
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> = override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries -> recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries ->
searchQueries.map { searchQueries.map { it.asExternalModel() }
it.asExternalModel()
}
} }
override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries()

@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
class DefaultSearchContentsRepository @Inject constructor( internal class DefaultSearchContentsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao, private val newsResourceDao: NewsResourceDao,
private val newsResourceFtsDao: NewsResourceFtsDao, private val newsResourceFtsDao: NewsResourceFtsDao,
private val topicDao: TopicDao, private val topicDao: TopicDao,

@ -45,7 +45,7 @@ private const val SYNC_BATCH_SIZE = 40
* Disk storage backed implementation of the [NewsRepository]. * Disk storage backed implementation of the [NewsRepository].
* Reads are exclusively from local storage to support offline access. * Reads are exclusively from local storage to support offline access.
*/ */
class OfflineFirstNewsRepository @Inject constructor( internal class OfflineFirstNewsRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource, private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val newsResourceDao: NewsResourceDao, private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao, private val topicDao: TopicDao,

@ -34,7 +34,7 @@ import javax.inject.Inject
* Disk storage backed implementation of the [TopicsRepository]. * Disk storage backed implementation of the [TopicsRepository].
* Reads are exclusively from local storage to support offline access. * Reads are exclusively from local storage to support offline access.
*/ */
class OfflineFirstTopicsRepository @Inject constructor( internal class OfflineFirstTopicsRepository @Inject constructor(
private val topicDao: TopicDao, private val topicDao: TopicDao,
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
) : TopicsRepository { ) : TopicsRepository {

@ -25,7 +25,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
class OfflineFirstUserDataRepository @Inject constructor( internal class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource, private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val analyticsHelper: AnalyticsHelper, private val analyticsHelper: AnalyticsHelper,
) : UserDataRepository { ) : UserDataRepository {

@ -26,10 +26,10 @@ import javax.inject.Inject
* Fake implementation of the [RecentSearchRepository] * Fake implementation of the [RecentSearchRepository]
*/ */
class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository {
override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { /* no-op */ } override suspend fun insertOrReplaceRecentSearch(searchQuery: String) = Unit
override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> = override fun getRecentSearchQueries(limit: Int): Flow<List<RecentSearchQuery>> =
flowOf(emptyList()) flowOf(emptyList())
override suspend fun clearRecentSearches() { /* no-op */ } override suspend fun clearRecentSearches() = Unit
} }

@ -27,7 +27,7 @@ import javax.inject.Inject
*/ */
class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository {
override suspend fun populateFtsData() { /* no-op */ } override suspend fun populateFtsData() = Unit
override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf() override fun searchContents(searchQuery: String): Flow<SearchResult> = flowOf()
override fun getSearchContentsCount(): Flow<Int> = flowOf(1) override fun getSearchContentsCount(): Flow<Int> = flowOf(1)
} }

@ -55,9 +55,8 @@ class FakeTopicsRepository @Inject constructor(
) )
}.flowOn(ioDispatcher) }.flowOn(ioDispatcher)
override fun getTopic(id: String): Flow<Topic> { override fun getTopic(id: String): Flow<Topic> = getTopics()
return getTopics().map { it.first { topic -> topic.id == id } } .map { it.first { topic -> topic.id == id } }
}
override suspend fun syncWith(synchronizer: Synchronizer) = true override suspend fun syncWith(synchronizer: Synchronizer) = true
} }

@ -33,7 +33,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import javax.inject.Inject import javax.inject.Inject
class ConnectivityManagerNetworkMonitor @Inject constructor( internal class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
) : NetworkMonitor { ) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow { override val isOnline: Flow<Boolean> = callbackFlow {

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

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

@ -34,9 +34,7 @@ val nonPresentInterestsIds = setOf("2")
*/ */
class TestNewsResourceDao : NewsResourceDao { class TestNewsResourceDao : NewsResourceDao {
private var entitiesStateFlow = MutableStateFlow( private val entitiesStateFlow = MutableStateFlow(emptyList<NewsResourceEntity>())
emptyList<NewsResourceEntity>(),
)
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf() internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
@ -131,7 +129,7 @@ class TestNewsResourceDao : NewsResourceDao {
override suspend fun deleteNewsResources(ids: List<String>) { override suspend fun deleteNewsResources(ids: List<String>) {
val idSet = ids.toSet() val idSet = ids.toSet()
entitiesStateFlow.update { entities -> entitiesStateFlow.update { entities ->
entities.filterNot { idSet.contains(it.id) } entities.filterNot { it.id in idSet }
} }
} }
} }

@ -91,10 +91,9 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
} }
} }
fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> = fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> = when (version) {
when (version) {
null -> this null -> this
else -> this.filter { it.changeListVersion > version } else -> filter { it.changeListVersion > version }
} }
/** /**
@ -105,7 +104,7 @@ private fun <T> List<T>.matchIds(
idGetter: (T) -> String, idGetter: (T) -> String,
) = when (ids) { ) = when (ids) {
null -> this null -> this
else -> ids.toSet().let { idSet -> this.filter { idSet.contains(idGetter(it)) } } else -> ids.toSet().let { idSet -> filter { idGetter(it) in idSet } }
} }
/** /**

@ -28,20 +28,15 @@ import kotlinx.coroutines.flow.update
*/ */
class TestTopicDao : TopicDao { class TestTopicDao : TopicDao {
private var entitiesStateFlow = MutableStateFlow( private val entitiesStateFlow = MutableStateFlow(emptyList<TopicEntity>())
emptyList<TopicEntity>(),
)
override fun getTopicEntity(topicId: String): Flow<TopicEntity> { override fun getTopicEntity(topicId: String): Flow<TopicEntity> =
throw NotImplementedError("Unused in tests") throw NotImplementedError("Unused in tests")
}
override fun getTopicEntities(): Flow<List<TopicEntity>> = override fun getTopicEntities(): Flow<List<TopicEntity>> = entitiesStateFlow
entitiesStateFlow
override fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> = override fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>> =
getTopicEntities() getTopicEntities().map { topics -> topics.filter { it.id in ids } }
.map { topics -> topics.filter { it.id in ids } }
override suspend fun getOneOffTopicEntities(): List<TopicEntity> = emptyList() override suspend fun getOneOffTopicEntities(): List<TopicEntity> = emptyList()
@ -55,15 +50,11 @@ class TestTopicDao : TopicDao {
override suspend fun upsertTopics(entities: List<TopicEntity>) { override suspend fun upsertTopics(entities: List<TopicEntity>) {
// Overwrite old values with new values // Overwrite old values with new values
entitiesStateFlow.update { oldValues -> entitiesStateFlow.update { oldValues -> (entities + oldValues).distinctBy(TopicEntity::id) }
(entities + oldValues).distinctBy(TopicEntity::id)
}
} }
override suspend fun deleteTopics(ids: List<String>) { override suspend fun deleteTopics(ids: List<String>) {
val idSet = ids.toSet() val idSet = ids.toSet()
entitiesStateFlow.update { entities -> entitiesStateFlow.update { entities -> entities.filterNot { it.id in idSet } }
entities.filterNot { idSet.contains(it.id) }
}
} }
} }

@ -30,9 +30,8 @@ android {
} }
dependencies { dependencies {
implementation(projects.core.model) api(projects.core.model)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
androidTestImplementation(projects.core.testing) androidTestImplementation(projects.core.testing)

@ -28,7 +28,7 @@ import dagger.hilt.components.SingletonComponent
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DaosModule { internal object DaosModule {
@Provides @Provides
fun providesTopicsDao( fun providesTopicsDao(
database: NiaDatabase, database: NiaDatabase,

@ -28,7 +28,7 @@ import androidx.room.migration.AutoMigrationSpec
* from and Y is the schema version you're migrating to. The class should implement * from and Y is the schema version you're migrating to. The class should implement
* `AutoMigrationSpec`. * `AutoMigrationSpec`.
*/ */
object DatabaseMigrations { internal object DatabaseMigrations {
@RenameColumn( @RenameColumn(
tableName = "topics", tableName = "topics",

@ -27,7 +27,7 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DatabaseModule { internal object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun providesNiaDatabase( fun providesNiaDatabase(

@ -63,7 +63,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter
@TypeConverters( @TypeConverters(
InstantConverter::class, InstantConverter::class,
) )
abstract class NiaDatabase : RoomDatabase() { internal abstract class NiaDatabase : RoomDatabase() {
abstract fun topicDao(): TopicDao abstract fun topicDao(): TopicDao
abstract fun newsResourceDao(): NewsResourceDao abstract fun newsResourceDao(): NewsResourceDao
abstract fun topicFtsDao(): TopicFtsDao abstract fun topicFtsDao(): TopicFtsDao

@ -19,7 +19,7 @@ package com.google.samples.apps.nowinandroid.core.database.util
import androidx.room.TypeConverter import androidx.room.TypeConverter
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
class InstantConverter { internal class InstantConverter {
@TypeConverter @TypeConverter
fun longToInstant(value: Long?): Instant? = fun longToInstant(value: Long?): Instant? =
value?.let(Instant::fromEpochMilliseconds) value?.let(Instant::fromEpochMilliseconds)

@ -51,5 +51,5 @@ androidComponents.beforeVariants {
} }
dependencies { dependencies {
implementation(libs.protobuf.kotlin.lite) api(libs.protobuf.kotlin.lite)
} }

@ -23,10 +23,7 @@ android {
} }
dependencies { dependencies {
api(projects.core.datastore) implementation(libs.hilt.android.testing)
api(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.testing) implementation(projects.core.datastore)
} }

@ -35,7 +35,7 @@ import javax.inject.Singleton
components = [SingletonComponent::class], components = [SingletonComponent::class],
replaces = [DataStoreModule::class], replaces = [DataStoreModule::class],
) )
object TestDataStoreModule { internal object TestDataStoreModule {
@Provides @Provides
@Singleton @Singleton

@ -33,13 +33,12 @@ android {
} }
dependencies { dependencies {
api(libs.androidx.dataStore.core)
api(projects.core.datastoreProto) api(projects.core.datastoreProto)
api(projects.core.model)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.model)
implementation(libs.androidx.dataStore.core)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.protobuf.kotlin.lite)
testImplementation(projects.core.datastoreTest) testImplementation(projects.core.datastoreTest)
testImplementation(projects.core.testing) testImplementation(libs.kotlinx.coroutines.test)
} }

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

Loading…
Cancel
Save