Merge branch 'main' into issue-945

pull/951/head
Simon Marquis 7 months ago committed by GitHub
commit 0699efc592
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -1,16 +0,0 @@
---
name: Pull request
about: Create a pull request
label: 'triage me'
---
Thank you for opening a Pull Request!
Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [ ] Make sure to open a GitHub issue as a bug/feature request before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea
- [ ] Ensure the tests and linter pass (`./gradlew --init-script gradle/init.gradle.kts spotlessApply` to automatically apply formatting)
- [ ] Appropriate docs were updated (if necessary)
Is this your first Pull Request?
- [ ] Run `./tools/setup.sh`
- [ ] Import the code formatting style as explained in [the setup script](/tools/setup.sh#L40).
Fixes #<issue_number_goes_here> 🦕

@ -19,7 +19,6 @@ org.gradle.parallel=true
org.gradle.workers.max=2 org.gradle.workers.max=2
kotlin.incremental=false kotlin.incremental=false
kotlin.compiler.execution.strategy=in-process
# Controls KotlinOptions.allWarningsAsErrors. # Controls KotlinOptions.allWarningsAsErrors.
# This value used in CI and is currently set to false. # This value used in CI and is currently set to false.

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

@ -0,0 +1,17 @@
Thanks for submitting a pull request. Please include the following information.
**What I have done and why**
Include a summary of what your pull request contains, and why you have made these changes.
Fixes #<issue_number_goes_here>
**Do tests pass?**
- [ ] Run local tests on `DemoDebug` variant: `./gradlew testDemoDebug`
- [ ] Check formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply`
**Is this your first pull request?**
- [ ] [Sign the CLA](https://cla.developers.google.com/)
- [ ] Run `./tools/setup.sh`
- [ ] Import the code formatting style as explained in [the setup script](/tools/setup.sh#L40).

@ -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"
}
]
}

@ -11,45 +11,8 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
build: test_and_apk:
runs-on: ubuntu-latest name: "Local tests and APKs"
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Check spotless
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
- name: Build all build type and flavor permutations
run: ./gradlew assemble
- name: Upload build outputs (APKs)
uses: actions/upload-artifact@v3
with:
name: APKs
path: '**/build/outputs/apk/**/*.apk'
- name: Run local tests
run: ./gradlew testDemoDebug testProdDebug
test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@ -59,7 +22,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v1
@ -68,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
@ -76,6 +39,12 @@ 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
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
- 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
@ -96,7 +65,7 @@ jobs:
./gradlew recordRoborazziDemoDebug ./gradlew recordRoborazziDemoDebug
- name: Push new screenshots if available - name: Push new screenshots if available
uses: stefanzweifel/git-auto-commit-action@v4 uses: stefanzweifel/git-auto-commit-action@v5
if: steps.screenshotsrecord.outcome == 'success' if: steps.screenshotsrecord.outcome == 'success'
with: with:
file_pattern: '*/*.png' file_pattern: '*/*.png'
@ -106,11 +75,30 @@ 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 run: ./gradlew testDemoDebug :lint:test
# Replace task exclusions with `-Pandroidx.baselineprofile.skipgeneration` when
# https://android-review.googlesource.com/c/platform/frameworks/support/+/2602790 landed in a
# release build
- name: Build all build type and flavor permutations
run: ./gradlew :app:assemble :benchmarks:assemble
-x pixel6Api33ProdNonMinifiedReleaseAndroidTest
-x pixel6Api33ProdNonMinifiedBenchmarkAndroidTest
-x pixel6Api33DemoNonMinifiedReleaseAndroidTest
-x pixel6Api33DemoNonMinifiedBenchmarkAndroidTest
-x collectDemoNonMinifiedReleaseBaselineProfile
-x collectDemoNonMinifiedBenchmarkBaselineProfile
-x collectProdNonMinifiedReleaseBaselineProfile
-x collectProdNonMinifiedBenchmarkBaselineProfile
- name: Upload build outputs (APKs)
uses: actions/upload-artifact@v4
with:
name: APKs
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'
@ -120,13 +108,15 @@ 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'
- name: Check badging
run: ./gradlew :app:checkProdReleaseBadging
androidTest: androidTest:
needs: build
runs-on: macOS-latest # enables hardware acceleration in the virtual machine runs-on: macOS-latest # enables hardware acceleration in the virtual machine
timeout-minutes: 55 timeout-minutes: 55
strategy: strategy:
@ -135,13 +125,13 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Copy CI gradle.properties - name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- 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
@ -149,8 +139,8 @@ jobs:
- name: Setup Gradle - name: Setup Gradle
uses: gradle/gradle-build-action@v2 uses: gradle/gradle-build-action@v2
- name: Build AndroidTest apps - name: Build projects before running emulator
run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest --daemon run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest
- name: Run instrumentation tests - name: Run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2 uses: reactivecircus/android-emulator-runner@v2
@ -164,49 +154,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'
androidTest-GMD:
needs: build
runs-on: macOS-latest # enables hardware acceleration in the virtual machine
timeout-minutes: 90
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: 17
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Accept Android licenses
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true
- name: Build AndroidTest apps
run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest
- name: Run instrumented tests with GMD
run: ./gradlew ciDemoDebugAndroidTest --no-parallel --max-workers=1
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
- name: Upload test reports
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: test-reports-GMD
path: '**/build/reports/androidTests'
- name: Print disk space usage
if: failure()
run: df -h

@ -7,12 +7,12 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: macos-latest
timeout-minutes: 45 timeout-minutes: 120
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Validate Gradle Wrapper - name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1 uses: gradle/wrapper-validation-action@v1
@ -21,14 +21,24 @@ 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
- name: Build app - name: Install GMD image for baseline profile generation
run: ./gradlew :app:assembleDemoRelease run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager "system-images;android-33;aosp_atd;x86_64"
- name: Accept Android licenses
run: yes | "$ANDROID_HOME"/cmdline-tools/latest/bin/sdkmanager --licenses || true
- name: Build release variant including baseline profile generation
run: ./gradlew :app:assembleDemoRelease
-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-Pandroid.experimental.androidTest.numManagedDeviceShards=1
-Pandroid.experimental.testOptions.managedDevices.maxConcurrentDevices=1
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: actions/create-release@v1 uses: actions/create-release@v1

@ -1,6 +1,6 @@
<component name="CopyrightManager"> <component name="CopyrightManager">
<copyright> <copyright>
<option name="notice" value="Copyright &amp;#36;today.year The Android Open Source Project&#10; &#10; Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10; you may not use this file except in compliance with the License.&#10; You may obtain a copy of the License at&#10; &#10; https://www.apache.org/licenses/LICENSE-2.0&#10; &#10; Unless required by applicable law or agreed to in writing, software&#10; distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10; See the License for the specific language governing permissions and&#10; limitations under the License." /> <option name="notice" value="Copyright &amp;#36;today.year The Android Open Source Project&#10;&#10;Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);&#10;you may not use this file except in compliance with the License.&#10;You may obtain a copy of the License at&#10;&#10; https://www.apache.org/licenses/LICENSE-2.0&#10;&#10;Unless required by applicable law or agreed to in writing, software&#10;distributed under the License is distributed on an &quot;AS IS&quot; BASIS,&#10;WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.&#10;See the License for the specific language governing permissions and&#10;limitations under the License." />
<option name="myName" value="The Android Open Source Project" /> <option name="myName" value="The Android Open Source Project" />
</copyright> </copyright>
</component> </component>

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

@ -32,8 +32,8 @@ import com.google.samples.apps.nowinandroid.NiaFlavor
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.application") alias(libs.plugins.nowinandroid.android.application)
id("nowinandroid.android.application.compose") alias(libs.plugins.nowinandroid.android.application.compose)
} }
android { android {
@ -65,7 +65,11 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:designsystem")) implementation(projects.core.designsystem)
implementation(project(":core:ui")) implementation(projects.core.ui)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
} }
dependencyGuard {
configuration("releaseRuntimeClasspath")
}

@ -0,0 +1,167 @@
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.0
androidx.annotation:annotation-jvm:1.6.0
androidx.annotation:annotation:1.6.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:1.2.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-livedata: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.datastore:datastore-core:1.0.0
androidx.datastore:datastore:1.0.0
androidx.documentfile:documentfile: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.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.6.1
androidx.lifecycle:lifecycle-common:2.6.1
androidx.lifecycle:lifecycle-livedata-core:2.6.1
androidx.lifecycle:lifecycle-livedata:2.6.1
androidx.lifecycle:lifecycle-process:2.6.1
androidx.lifecycle:lifecycle-runtime-ktx:2.6.1
androidx.lifecycle:lifecycle-runtime:2.6.1
androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1
androidx.lifecycle:lifecycle-viewmodel:2.6.1
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-alpha04
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.room:room-common:2.6.0
androidx.room:room-ktx:2.6.0
androidx.room:room-runtime:2.6.0
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
com.caverock:androidsvg-aar:1.4
com.google.accompanist:accompanist-drawablepainter:0.30.1
com.google.android.datatransport:transport-api:3.0.0
com.google.android.datatransport:transport-backend-cct:3.1.8
com.google.android.datatransport:transport-runtime:3.1.8
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-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.48.1
com.google.dagger:dagger:2.48.1
com.google.dagger:hilt-android:2.48.1
com.google.dagger:hilt-core:2.48.1
com.google.errorprone:error_prone_annotations:2.11.0
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-datatransport:18.1.7
com.google.firebase:firebase-encoders-json:18.0.0
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.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.4.0
io.coil-kt:coil-compose-base:2.4.0
io.coil-kt:coil-compose:2.4.0
io.coil-kt:coil-svg:2.4.0
io.coil-kt:coil:2.4.0
javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10
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.10
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-play-services:1.7.3
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.4.1
org.jetbrains.kotlinx:kotlinx-datetime:0.4.1
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

@ -32,8 +32,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -170,13 +171,13 @@ fun NiaCatalog() {
item { Text("Chips", Modifier.padding(top = 16.dp)) } item { Text("Chips", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
var firstChecked by remember { mutableStateOf(false) } var firstChecked by rememberSaveable { mutableStateOf(false) }
NiaFilterChip( NiaFilterChip(
selected = firstChecked, selected = firstChecked,
onSelectedChange = { checked -> firstChecked = checked }, onSelectedChange = { checked -> firstChecked = checked },
label = { Text(text = "Enabled") }, label = { Text(text = "Enabled") },
) )
var secondChecked by remember { mutableStateOf(true) } var secondChecked by rememberSaveable { mutableStateOf(true) }
NiaFilterChip( NiaFilterChip(
selected = secondChecked, selected = secondChecked,
onSelectedChange = { checked -> secondChecked = checked }, onSelectedChange = { checked -> secondChecked = checked },
@ -199,7 +200,7 @@ fun NiaCatalog() {
item { Text("Icon buttons", Modifier.padding(top = 16.dp)) } item { Text("Icon buttons", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
var firstChecked by remember { mutableStateOf(false) } var firstChecked by rememberSaveable { mutableStateOf(false) }
NiaIconToggleButton( NiaIconToggleButton(
checked = firstChecked, checked = firstChecked,
onCheckedChange = { checked -> firstChecked = checked }, onCheckedChange = { checked -> firstChecked = checked },
@ -216,7 +217,7 @@ fun NiaCatalog() {
) )
}, },
) )
var secondChecked by remember { mutableStateOf(true) } var secondChecked by rememberSaveable { mutableStateOf(true) }
NiaIconToggleButton( NiaIconToggleButton(
checked = secondChecked, checked = secondChecked,
onCheckedChange = { checked -> secondChecked = checked }, onCheckedChange = { checked -> secondChecked = checked },
@ -272,14 +273,14 @@ fun NiaCatalog() {
item { Text("View toggle", Modifier.padding(top = 16.dp)) } item { Text("View toggle", Modifier.padding(top = 16.dp)) }
item { item {
FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
var firstExpanded by remember { mutableStateOf(false) } var firstExpanded by rememberSaveable { mutableStateOf(false) }
NiaViewToggleButton( NiaViewToggleButton(
expanded = firstExpanded, expanded = firstExpanded,
onExpandedChange = { expanded -> firstExpanded = expanded }, onExpandedChange = { expanded -> firstExpanded = expanded },
compactText = { Text(text = "Compact view") }, compactText = { Text(text = "Compact view") },
expandedText = { Text(text = "Expanded view") }, expandedText = { Text(text = "Expanded view") },
) )
var secondExpanded by remember { mutableStateOf(true) } var secondExpanded by rememberSaveable { mutableStateOf(true) }
NiaViewToggleButton( NiaViewToggleButton(
expanded = secondExpanded, expanded = secondExpanded,
onExpandedChange = { expanded -> secondExpanded = expanded }, onExpandedChange = { expanded -> secondExpanded = expanded },
@ -318,7 +319,7 @@ fun NiaCatalog() {
} }
item { Text("Tabs", Modifier.padding(top = 16.dp)) } item { Text("Tabs", Modifier.padding(top = 16.dp)) }
item { item {
var selectedTabIndex by remember { mutableStateOf(0) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) }
val titles = listOf("Topics", "People") val titles = listOf("Topics", "People")
NiaTabRow(selectedTabIndex = selectedTabIndex) { NiaTabRow(selectedTabIndex = selectedTabIndex) {
titles.forEachIndexed { index, title -> titles.forEachIndexed { index, title ->
@ -332,7 +333,7 @@ fun NiaCatalog() {
} }
item { Text("Navigation", Modifier.padding(top = 16.dp)) } item { Text("Navigation", Modifier.padding(top = 16.dp)) }
item { item {
var selectedItem by remember { mutableStateOf(0) } var selectedItem by rememberSaveable { mutableIntStateOf(0) }
val items = listOf("For you", "Saved", "Interests") val items = listOf("For you", "Saved", "Interests")
val icons = listOf( val icons = listOf(
NiaIcons.UpcomingBorder, NiaIcons.UpcomingBorder,

@ -16,14 +16,15 @@
import com.google.samples.apps.nowinandroid.NiaBuildType import com.google.samples.apps.nowinandroid.NiaBuildType
plugins { plugins {
id("nowinandroid.android.application") alias(libs.plugins.nowinandroid.android.application)
id("nowinandroid.android.application.compose") alias(libs.plugins.nowinandroid.android.application.compose)
id("nowinandroid.android.application.flavors") alias(libs.plugins.nowinandroid.android.application.flavors)
id("nowinandroid.android.application.jacoco") alias(libs.plugins.nowinandroid.android.application.jacoco)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
id("jacoco") id("jacoco")
id("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)
} }
android { android {
@ -43,7 +44,7 @@ android {
debug { debug {
applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix
} }
val release by getting { val release = getByName("release") {
isMinifyEnabled = true isMinifyEnabled = true
applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
@ -52,6 +53,8 @@ android {
// who clones the code to sign and run the release variant, use the debug signing key. // who clones the code to sign and run the release variant, use the debug signing key.
// TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
// Ensure Baseline Profile is fresh for release builds.
baselineProfile.automaticGenerationDuringBuild = true
} }
create("benchmark") { create("benchmark") {
// Enable all the optimizations from release build through initWith(release). // Enable all the optimizations from release build through initWith(release).
@ -80,31 +83,31 @@ android {
} }
dependencies { dependencies {
implementation(project(":feature:interests")) implementation(projects.feature.interests)
implementation(project(":feature:foryou")) implementation(projects.feature.foryou)
implementation(project(":feature:bookmarks")) implementation(projects.feature.bookmarks)
implementation(project(":feature:topic")) implementation(projects.feature.topic)
implementation(project(":feature:search")) implementation(projects.feature.search)
implementation(project(":feature:settings")) implementation(projects.feature.settings)
implementation(project(":core:common")) implementation(projects.core.common)
implementation(project(":core:ui")) implementation(projects.core.ui)
implementation(project(":core:designsystem")) implementation(projects.core.designsystem)
implementation(project(":core:data")) implementation(projects.core.data)
implementation(project(":core:model")) implementation(projects.core.model)
implementation(project(":core:analytics")) implementation(projects.core.analytics)
implementation(project(":sync:work")) implementation(projects.sync.work)
androidTestImplementation(project(":core:testing")) androidTestImplementation(projects.core.testing)
androidTestImplementation(project(":core:datastore-test")) androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(project(":core:data-test")) androidTestImplementation(projects.core.dataTest)
androidTestImplementation(project(":core:network")) androidTestImplementation(projects.core.network)
androidTestImplementation(libs.androidx.navigation.testing) androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.accompanist.testharness) androidTestImplementation(libs.accompanist.testharness)
androidTestImplementation(kotlin("test")) androidTestImplementation(kotlin("test"))
debugImplementation(libs.androidx.compose.ui.testManifest) debugImplementation(libs.androidx.compose.ui.testManifest)
debugImplementation(project(":ui-test-hilt-manifest")) debugImplementation(projects.uiTestHiltManifest)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
@ -121,15 +124,27 @@ dependencies {
implementation(libs.kotlinx.coroutines.guava) implementation(libs.kotlinx.coroutines.guava)
implementation(libs.coil.kt) implementation(libs.coil.kt)
baselineProfile(project(":benchmarks"))
// Core functions // Core functions
testImplementation(project(":core:testing")) testImplementation(projects.core.testing)
testImplementation(project(":core:datastore-test")) testImplementation(projects.core.datastoreTest)
testImplementation(project(":core:data-test")) testImplementation(projects.core.dataTest)
testImplementation(project(":core:network")) testImplementation(projects.core.network)
testImplementation(libs.androidx.navigation.testing) testImplementation(libs.androidx.navigation.testing)
testImplementation(libs.accompanist.testharness) testImplementation(libs.accompanist.testharness)
testImplementation(libs.work.testing) testImplementation(libs.work.testing)
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
kaptTest(libs.hilt.compiler) kspTest(libs.hilt.compiler)
}
baselineProfile {
// Don't build on every iteration of a full assemble.
// Instead enable generation directly for the release build variant.
automaticGenerationDuringBuild = false
}
dependencyGuard {
configuration("prodReleaseRuntimeClasspath")
} }

@ -0,0 +1,211 @@
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.0
androidx.annotation:annotation-jvm:1.6.0
androidx.annotation:annotation:1.6.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-ktx:1.1.0
androidx.collection:collection:1.2.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-livedata:1.5.4
androidx.compose.runtime:runtime-saveable-android:1.5.4
androidx.compose.runtime:runtime-saveable:1.5.4
androidx.compose.runtime:runtime-tracing:1.0.0-alpha03
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-ktx:2.6.2
androidx.lifecycle:lifecycle-livedata-core:2.6.2
androidx.lifecycle:lifecycle-livedata-ktx: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.0
androidx.room:room-ktx:2.6.0
androidx.room:room-runtime:2.6.0
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.2.0-alpha02
androidx.tracing:tracing-perfetto-common:1.0.0-alpha11
androidx.tracing:tracing-perfetto:1.0.0-alpha11
androidx.tracing:tracing:1.2.0-alpha02
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
androidx.window:window:1.1.0
androidx.work:work-runtime-ktx:2.9.0-rc01
androidx.work:work-runtime:2.9.0-rc01
com.caverock:androidsvg-aar:1.4
com.google.accompanist:accompanist-drawablepainter:0.30.1
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.48.1
com.google.dagger:dagger:2.48.1
com.google.dagger:hilt-android:2.48.1
com.google.dagger:hilt-core:2.48.1
com.google.errorprone:error_prone_annotations:2.11.0
com.google.firebase:firebase-abt:21.1.1
com.google.firebase:firebase-analytics-ktx:21.4.0
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.4.0
io.coil-kt:coil-compose-base:2.4.0
io.coil-kt:coil-compose:2.4.0
io.coil-kt:coil-svg:2.4.0
io.coil-kt:coil:2.4.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.10
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.10
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.4.1
org.jetbrains.kotlinx:kotlinx-datetime:0.4.1
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

@ -0,0 +1,121 @@
package: name='com.google.samples.apps.nowinandroid' versionCode='8' versionName='0.1.2' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
sdkVersion:'21'
targetSdkVersion:'34'
uses-permission: name='android.permission.INTERNET'
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
uses-permission: name='android.permission.POST_NOTIFICATIONS'
uses-permission: name='android.permission.WAKE_LOCK'
uses-permission: name='com.google.android.c2dm.permission.RECEIVE'
uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE'
uses-permission: name='android.permission.RECEIVE_BOOT_COMPLETED'
uses-permission: name='android.permission.FOREGROUND_SERVICE'
uses-permission: name='com.google.samples.apps.nowinandroid.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
application-label:'Now in Android'
application-label-af:'Now in Android'
application-label-am:'Now in Android'
application-label-ar:'Now in Android'
application-label-as:'Now in Android'
application-label-az:'Now in Android'
application-label-be:'Now in Android'
application-label-bg:'Now in Android'
application-label-bn:'Now in Android'
application-label-bs:'Now in Android'
application-label-ca:'Now in Android'
application-label-cs:'Now in Android'
application-label-da:'Now in Android'
application-label-de:'Now in Android'
application-label-el:'Now in Android'
application-label-en-AU:'Now in Android'
application-label-en-CA:'Now in Android'
application-label-en-GB:'Now in Android'
application-label-en-IN:'Now in Android'
application-label-en-XC:'Now in Android'
application-label-es:'Now in Android'
application-label-es-US:'Now in Android'
application-label-et:'Now in Android'
application-label-eu:'Now in Android'
application-label-fa:'Now in Android'
application-label-fi:'Now in Android'
application-label-fr:'Now in Android'
application-label-fr-CA:'Now in Android'
application-label-gl:'Now in Android'
application-label-gu:'Now in Android'
application-label-hi:'Now in Android'
application-label-hr:'Now in Android'
application-label-hu:'Now in Android'
application-label-hy:'Now in Android'
application-label-in:'Now in Android'
application-label-is:'Now in Android'
application-label-it:'Now in Android'
application-label-iw:'Now in Android'
application-label-ja:'Now in Android'
application-label-ka:'Now in Android'
application-label-kk:'Now in Android'
application-label-km:'Now in Android'
application-label-kn:'Now in Android'
application-label-ko:'Now in Android'
application-label-ky:'Now in Android'
application-label-lo:'Now in Android'
application-label-lt:'Now in Android'
application-label-lv:'Now in Android'
application-label-mk:'Now in Android'
application-label-ml:'Now in Android'
application-label-mn:'Now in Android'
application-label-mr:'Now in Android'
application-label-ms:'Now in Android'
application-label-my:'Now in Android'
application-label-nb:'Now in Android'
application-label-ne:'Now in Android'
application-label-nl:'Now in Android'
application-label-or:'Now in Android'
application-label-pa:'Now in Android'
application-label-pl:'Now in Android'
application-label-pt:'Now in Android'
application-label-pt-BR:'Now in Android'
application-label-pt-PT:'Now in Android'
application-label-ro:'Now in Android'
application-label-ru:'Now in Android'
application-label-si:'Now in Android'
application-label-sk:'Now in Android'
application-label-sl:'Now in Android'
application-label-sq:'Now in Android'
application-label-sr:'Now in Android'
application-label-sr-Latn:'Now in Android'
application-label-sv:'Now in Android'
application-label-sw:'Now in Android'
application-label-ta:'Now in Android'
application-label-te:'Now in Android'
application-label-th:'Now in Android'
application-label-tl:'Now in Android'
application-label-tr:'Now in Android'
application-label-uk:'Now in Android'
application-label-ur:'Now in Android'
application-label-uz:'Now in Android'
application-label-vi:'Now in Android'
application-label-zh-CN:'Now in Android'
application-label-zh-HK:'Now in Android'
application-label-zh-TW:'Now in Android'
application-label-zu:'Now in Android'
application-icon-120:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-160:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-240:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-320:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-480:'res/mipmap-anydpi-v26/ic_launcher.xml'
application-icon-640:'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'
launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon=''
uses-library-not-required:'androidx.window.extensions'
uses-library-not-required:'androidx.window.sidecar'
uses-library-not-required:'android.ext.adservices'
feature-group: label=''
uses-feature: name='android.hardware.faketouch'
uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'
main
other-activities
other-receivers
other-services
supports-screens: 'small' 'normal' 'large' 'xlarge'
supports-any-density: 'true'
locales: '--_--' 'af' 'am' 'ar' 'as' 'az' 'be' 'bg' 'bn' 'bs' 'ca' 'cs' 'da' 'de' 'el' 'en-AU' 'en-CA' 'en-GB' 'en-IN' 'en-XC' 'es' 'es-US' 'et' 'eu' 'fa' 'fi' 'fr' 'fr-CA' 'gl' 'gu' 'hi' 'hr' 'hu' 'hy' 'in' 'is' 'it' 'iw' 'ja' 'ka' 'kk' 'km' 'kn' 'ko' 'ky' 'lo' 'lt' 'lv' 'mk' 'ml' 'mn' 'mr' 'ms' 'my' 'nb' 'ne' 'nl' 'or' 'pa' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'si' 'sk' 'sl' 'sq' 'sr' 'sr-Latn' 'sv' 'sw' 'ta' 'te' 'th' 'tl' 'tr' 'uk' 'ur' 'uz' 'vi' 'zh-CN' 'zh-HK' 'zh-TW' 'zu'
densities: '120' '160' '240' '320' '480' '640' '65534'

@ -42,7 +42,7 @@ import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.runBlocking
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -51,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
/** /**
@ -93,15 +93,15 @@ class NavigationTest {
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()
@ -166,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()
} }
@ -265,12 +268,14 @@ class NavigationTest {
} }
@Test @Test
fun navigationBar_multipleBackStackInterests() = runTest { fun navigationBar_multipleBackStackInterests() {
composeTestRule.apply { composeTestRule.apply {
onNodeWithText(interests).performClick() onNodeWithText(interests).performClick()
// Select the last topic // Select the last topic
val topic = topicsRepository.getTopics().first().sortedBy(Topic::name).last().name val topic = runBlocking {
topicsRepository.getTopics().first().sortedBy(Topic::name).last().name
}
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic)) onNodeWithTag("interests:topics").performScrollToNode(hasText(topic))
onNodeWithText(topic).performClick() onNodeWithText(topic).performClick()

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<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_foreground_tint">#FF006780</color>
</resources>

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<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_foreground_tint">#FF006780</color>
</resources>

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Copyright 2022 The Android Open Source Project Copyright 2023 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,14 +14,9 @@
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <resources>
xmlns:tools="http://schemas.android.com/tools"> <!-- 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. -->
<application> <color name="ic_launcher_background_tint">#FFFFFF</color>
<service <color name="ic_launcher_foreground_tint">#FFA23F16</color>
android:name=".services.SyncNotificationsService" </resources>
android:exported="false"
tools:node="remove" />
</application>
</manifest>

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<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_foreground_tint">#FFA23F16</color>
</resources>

@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.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.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
@ -41,7 +41,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(

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

@ -183,11 +183,11 @@ fun NiaApp(
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,

@ -33,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
@ -91,9 +91,9 @@ 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
} }

@ -65,7 +65,7 @@ import javax.inject.Inject
@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 = "w1000dp-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 {
@ -162,7 +162,7 @@ class NiaAppScreenSizesScreenshotTests {
@Test @Test
fun compactWidth_compactHeight_showsNavigationBar() { fun compactWidth_compactHeight_showsNavigationBar() {
testNiaAppScreenshotWithSize( testNiaAppScreenshotWithSize(
610.dp, 400.dp,
400.dp, 400.dp,
"compactWidth_compactHeight_showsNavigationBar", "compactWidth_compactHeight_showsNavigationBar",
) )

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 234 KiB

@ -17,7 +17,8 @@ import com.google.samples.apps.nowinandroid.NiaBuildType
import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureFlavors
plugins { plugins {
id("nowinandroid.android.test") alias(libs.plugins.baselineprofile)
alias(libs.plugins.nowinandroid.android.test)
} }
android { android {
@ -62,10 +63,27 @@ android {
) )
} }
testOptions.managedDevices.devices {
create<com.android.build.api.dsl.ManagedVirtualDevice>("pixel6Api33") {
device = "Pixel 6"
apiLevel = 33
systemImageSource = "aosp"
}
}
targetProjectPath = ":app" targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true experimentalProperties["android.experimental.self-instrumenting"] = true
} }
baselineProfile {
// This specifies the managed devices to use that you run the tests on.
managedDevices += "pixel6Api33"
// Don't use a connected device but rely on a GMD for consistency between local and CI builds.
useConnectedDevices = false
}
dependencies { dependencies {
implementation(libs.androidx.benchmark.macro) implementation(libs.androidx.benchmark.macro)
implementation(libs.androidx.test.core) implementation(libs.androidx.test.core)
@ -75,9 +93,3 @@ dependencies {
implementation(libs.androidx.test.runner) implementation(libs.androidx.test.runner)
implementation(libs.androidx.test.uiautomator) implementation(libs.androidx.test.uiautomator)
} }
androidComponents {
beforeVariants {
it.enable = it.buildType == "benchmark"
}
}

@ -20,6 +20,10 @@ import android.Manifest.permission
import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.TIRAMISU import android.os.Build.VERSION_CODES.TIRAMISU
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.UiObject2
import androidx.test.uiautomator.Until
/** /**
* Because the app under test is different from the one running the instrumentation test, * Because the app under test is different from the one running the instrumentation test,
@ -42,3 +46,27 @@ fun MacrobenchmarkScope.allowNotifications() {
device.executeShellCommand(command) device.executeShellCommand(command)
} }
} }
/**
* Wraps starting the default activity, waiting for it to start and then allowing notifications in
* one convenient call.
*/
fun MacrobenchmarkScope.startActivityAndAllowNotifications() {
startActivityAndWait()
allowNotifications()
}
/**
* Waits for and returns the `niaTopAppBar`
*/
fun MacrobenchmarkScope.getTopAppBar(): UiObject2 {
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000)
return device.findObject(By.res("niaTopAppBar"))
}
/**
* Waits for an object on the top app bar, passed in as [selector].
*/
fun MacrobenchmarkScope.waitForObjectOnTopAppBar(selector: BySelector, timeout: Long = 2_000) {
getTopAppBar().wait(Until.hasObject(selector), timeout)
}

@ -0,0 +1,40 @@
/*
* 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.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen
import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
import org.junit.Rule
import org.junit.Test
/**
* Baseline Profile of the "Bookmarks" screen
*/
class BookmarksBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule()
@Test
fun generate() =
baselineProfileRule.collect(PACKAGE_NAME) {
startActivityAndAllowNotifications()
// Navigate to saved screen
goToBookmarksScreen()
}
}

@ -18,43 +18,27 @@ package com.google.samples.apps.nowinandroid.baselineprofile
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.allowNotifications
import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.interests.goToInterestsScreen import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
/** /**
* Generates a baseline profile which can be copied to `app/src/main/baseline-prof.txt`. * Baseline Profile of the "For You" screen
*/ */
class BaselineProfileGenerator { class ForYouBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule() @get:Rule val baselineProfileRule = BaselineProfileRule()
@Test @Test
fun generate() = fun generate() =
baselineProfileRule.collect(PACKAGE_NAME) { baselineProfileRule.collect(PACKAGE_NAME) {
// This block defines the app's critical user journey. Here we are interested in startActivityAndAllowNotifications()
// optimizing for app startup. But you can also navigate and scroll
// through your most important UI.
allowNotifications()
pressHome()
startActivityAndWait()
allowNotifications()
// Scroll the feed critical user journey // Scroll the feed critical user journey
forYouWaitForContent() forYouWaitForContent()
forYouSelectTopics(true) forYouSelectTopics(true)
forYouScrollFeedDownUp() forYouScrollFeedDownUp()
// Navigate to saved screen
goToBookmarksScreen()
// Navigate to interests screen
goToInterestsScreen()
interestsScrollTopicsDownUp()
} }
} }

@ -0,0 +1,42 @@
/*
* 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.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.interests.goToInterestsScreen
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
import org.junit.Rule
import org.junit.Test
/**
* Baseline Profile of the "Interests" screen
*/
class InterestsBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule()
@Test
fun generate() =
baselineProfileRule.collect(PACKAGE_NAME) {
startActivityAndAllowNotifications()
// Navigate to interests screen
goToInterestsScreen()
interestsScrollTopicsDownUp()
}
}

@ -0,0 +1,40 @@
/*
* 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.baselineprofile
import androidx.benchmark.macro.junit4.BaselineProfileRule
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
import org.junit.Rule
import org.junit.Test
/**
* Baseline Profile for app startup. This profile also enables using [Dex Layout Optimizations](https://developer.android.com/topic/performance/baselineprofiles/dex-layout-optimizations)
* via the `includeInStartupProfile` parameter.
*/
class StartupBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule()
@Test
fun generate() =
baselineProfileRule.collect(
PACKAGE_NAME,
includeInStartupProfile = true,
) {
startActivityAndAllowNotifications()
}
}

@ -18,13 +18,13 @@ package com.google.samples.apps.nowinandroid.bookmarks
import androidx.benchmark.macro.MacrobenchmarkScope import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar
fun MacrobenchmarkScope.goToBookmarksScreen() { fun MacrobenchmarkScope.goToBookmarksScreen() {
device.findObject(By.text("Saved")).click() val savedSelector = By.text("Saved")
val savedButton = device.findObject(savedSelector)
savedButton.click()
device.waitForIdle() device.waitForIdle()
// Wait until saved title are shown on screen // Wait until saved title are shown on screen
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) waitForObjectOnTopAppBar(savedSelector)
val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Saved")), 2_000)
} }

@ -22,6 +22,8 @@ import androidx.test.uiautomator.Until
import androidx.test.uiautomator.untilHasChildren import androidx.test.uiautomator.untilHasChildren
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
import com.google.samples.apps.nowinandroid.waitAndFindObject import com.google.samples.apps.nowinandroid.waitAndFindObject
import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar
import org.junit.Assert.fail
fun MacrobenchmarkScope.forYouWaitForContent() { fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded by checking if topics are loaded // Wait until content is loaded by checking if topics are loaded
@ -49,6 +51,9 @@ fun MacrobenchmarkScope.forYouSelectTopics(recheckTopicsIfChecked: Boolean = fal
var visited = 0 var visited = 0
while (visited < 3) { while (visited < 3) {
if (topics.childCount == 0) {
fail("No topics found, can't generate profile for ForYou page.")
}
// Selecting some topics, which will populate items in the feed. // Selecting some topics, which will populate items in the feed.
val topic = topics.children[index % topics.childCount] val topic = topics.children[index % topics.childCount]
// Find the checkable element to figure out whether it's checked or not // Find the checkable element to figure out whether it's checked or not
@ -99,7 +104,5 @@ fun MacrobenchmarkScope.setAppTheme(isDark: Boolean) {
device.findObject(By.text("OK")).click() device.findObject(By.text("OK")).click()
// Wait until the top app bar is visible on screen // Wait until the top app bar is visible on screen
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) waitForObjectOnTopAppBar(By.text("Now in Android"))
val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Now in Android")), 2_000)
} }

@ -22,7 +22,7 @@ import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -47,8 +47,7 @@ class ScrollForYouFeedBenchmark {
setupBlock = { setupBlock = {
// Start the app // Start the app
pressHome() pressHome()
startActivityAndWait() startActivityAndAllowNotifications()
allowNotifications()
}, },
) { ) {
forYouWaitForContent() forYouWaitForContent()

@ -20,21 +20,21 @@ import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.flingElementDownUp
import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar
fun MacrobenchmarkScope.goToInterestsScreen() { fun MacrobenchmarkScope.goToInterestsScreen() {
device.findObject(By.text("Interests")).click() device.findObject(By.text("Interests")).click()
device.waitForIdle() device.waitForIdle()
// Wait until interests are shown on screen // Wait until interests are shown on screen
device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) waitForObjectOnTopAppBar(By.text("Interests"))
val topAppBar = device.findObject(By.res("niaTopAppBar"))
topAppBar.wait(Until.hasObject(By.text("Interests")), 2_000)
// Wait until content is loaded by checking if interests are loaded // Wait until content is loaded by checking if interests are loaded
device.wait(Until.gone(By.res("loadingWheel")), 5_000) device.wait(Until.gone(By.res("loadingWheel")), 5_000)
} }
fun MacrobenchmarkScope.interestsScrollTopicsDownUp() { fun MacrobenchmarkScope.interestsScrollTopicsDownUp() {
val topicsList = device.wait(Until.findObject(By.res("interests:topics")), 2_000) device.wait(Until.hasObject(By.res("interests:topics")), 5_000)
val topicsList = device.findObject(By.res("interests:topics"))
device.flingElementDownUp(topicsList) device.flingElementDownUp(topicsList)
} }

@ -23,7 +23,7 @@ import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -47,8 +47,7 @@ class ScrollTopicListBenchmark {
setupBlock = { setupBlock = {
// Start the app // Start the app
pressHome() pressHome()
startActivityAndWait() startActivityAndAllowNotifications()
allowNotifications()
// Navigate to interests screen // Navigate to interests screen
device.findObject(By.text("Interests")).click() device.findObject(By.text("Interests")).click()
device.waitForIdle() device.waitForIdle()

@ -23,7 +23,7 @@ import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -47,8 +47,7 @@ class TopicsScreenRecompositionBenchmark {
setupBlock = { setupBlock = {
// Start the app // Start the app
pressHome() pressHome()
startActivityAndWait() startActivityAndAllowNotifications()
allowNotifications()
// Navigate to interests screen // Navigate to interests screen
device.findObject(By.text("Interests")).click() device.findObject(By.text("Interests")).click()
device.waitForIdle() device.waitForIdle()

@ -26,6 +26,7 @@ import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import com.google.samples.apps.nowinandroid.PACKAGE_NAME import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.allowNotifications import com.google.samples.apps.nowinandroid.allowNotifications
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -41,32 +42,33 @@ class StartupBenchmark {
val benchmarkRule = MacrobenchmarkRule() val benchmarkRule = MacrobenchmarkRule()
@Test @Test
fun startupNoCompilation() = startup(CompilationMode.None()) fun startupWithoutPreCompilation() = startup(CompilationMode.None())
@Test @Test
fun startupBaselineProfileDisabled() = startup( fun startupWithPartialCompilationAndDisabledBaselineProfile() = startup(
CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1), CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1),
) )
@Test @Test
fun startupBaselineProfile() = startup(CompilationMode.Partial(baselineProfileMode = Require)) fun startupPrecompiledWithBaselineProfile() =
startup(CompilationMode.Partial(baselineProfileMode = Require))
@Test @Test
fun startupFullCompilation() = startup(CompilationMode.Full()) fun startupFullyPrecompiled() = startup(CompilationMode.Full())
private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated( private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME, packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()), metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode, compilationMode = compilationMode,
iterations = 10, // More iterations result in higher statistical significance.
iterations = 20,
startupMode = COLD, startupMode = COLD,
setupBlock = { setupBlock = {
pressHome() pressHome()
allowNotifications() allowNotifications()
}, },
) { ) {
startActivityAndWait() startActivityAndAllowNotifications()
allowNotifications()
// Waits until the content is ready to capture Time To Full Display // Waits until the content is ready to capture Time To Full Display
forYouWaitForContent() forYouWaitForContent()
} }

@ -36,10 +36,20 @@ tasks.withType<KotlinCompile>().configureEach {
dependencies { dependencies {
compileOnly(libs.android.gradlePlugin) compileOnly(libs.android.gradlePlugin)
compileOnly(libs.android.tools.common)
compileOnly(libs.firebase.crashlytics.gradlePlugin) compileOnly(libs.firebase.crashlytics.gradlePlugin)
compileOnly(libs.firebase.performance.gradlePlugin) compileOnly(libs.firebase.performance.gradlePlugin)
compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin) compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.room.gradlePlugin)
implementation(libs.truth)
}
tasks {
validatePlugins {
enableStricterValidation = true
failOnWarning = true
}
} }
gradlePlugin { gradlePlugin {

@ -15,13 +15,16 @@
*/ */
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.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
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.getByType
class AndroidApplicationConventionPlugin : Plugin<Project> { class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
@ -30,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> {
@ -39,8 +43,9 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
} }
extensions.configure<ApplicationAndroidComponentsExtension> { extensions.configure<ApplicationAndroidComponentsExtension> {
configurePrintApksTask(this) configurePrintApksTask(this)
configureBadgingTasks(extensions.getByType<BaseExtension>(), this)
} }
} }
} }
} }

@ -23,17 +23,15 @@ class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) { override fun apply(target: Project) {
with(target) { with(target) {
with(pluginManager) { with(pluginManager) {
apply("com.google.devtools.ksp")
apply("dagger.hilt.android.plugin") apply("dagger.hilt.android.plugin")
// KAPT must go last to avoid build warnings.
// See: https://stackoverflow.com/questions/70550883/warning-the-following-options-were-not-recognized-by-any-processor-dagger-f
apply("org.jetbrains.kotlin.kapt")
} }
dependencies { dependencies {
"implementation"(libs.findLibrary("hilt.android").get()) "implementation"(libs.findLibrary("hilt.android").get())
"kapt"(libs.findLibrary("hilt.compiler").get()) "ksp"(libs.findLibrary("hilt.compiler").get())
"kaptAndroidTest"(libs.findLibrary("hilt.compiler").get()) "kspAndroidTest"(libs.findLibrary("hilt.compiler").get())
"kaptTest"(libs.findLibrary("hilt.compiler").get()) "kspTest"(libs.findLibrary("hilt.compiler").get())
} }
} }

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

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

@ -67,10 +67,10 @@ private fun Project.buildComposeMetricsParameters(): List<String> {
val metricParameters = mutableListOf<String>() val metricParameters = mutableListOf<String>()
val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics") val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics")
val relativePath = projectDir.relativeTo(rootDir) val relativePath = projectDir.relativeTo(rootDir)
val buildDir = layout.buildDirectory.get().asFile
val enableMetrics = (enableMetricsProvider.orNull == "true") val enableMetrics = (enableMetricsProvider.orNull == "true")
if (enableMetrics) { if (enableMetrics) {
val metricsFolder = rootProject.buildDir.resolve("compose-metrics").resolve(relativePath) val metricsFolder = buildDir.resolve("compose-metrics").resolve(relativePath)
metricParameters.add("-P") metricParameters.add("-P")
metricParameters.add( metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath
@ -80,7 +80,7 @@ private fun Project.buildComposeMetricsParameters(): List<String> {
val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports") val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports")
val enableReports = (enableReportsProvider.orNull == "true") val enableReports = (enableReportsProvider.orNull == "true")
if (enableReports) { if (enableReports) {
val reportsFolder = rootProject.buildDir.resolve("compose-reports").resolve(relativePath) val reportsFolder = buildDir.resolve("compose-reports").resolve(relativePath)
metricParameters.add("-P") metricParameters.add("-P")
metricParameters.add( metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath

@ -0,0 +1,160 @@
/*
* 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
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.android.SdkConstants
import com.google.common.truth.Truth.assertWithMessage
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
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.configurationcache.extensions.capitalized
import org.gradle.kotlin.dsl.register
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.process.ExecOperations
import java.io.File
import javax.inject.Inject
@CacheableTask
abstract class GenerateBadgingTask : DefaultTask() {
@get:OutputFile
abstract val badging: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val apk: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val aapt2Executable: RegularFileProperty
@get:Inject
abstract val execOperations: ExecOperations
@TaskAction
fun taskAction() {
execOperations.exec {
commandLine(
aapt2Executable.get().asFile.absolutePath,
"dump",
"badging",
apk.get().asFile.absolutePath,
)
standardOutput = badging.asFile.get().outputStream()
}
}
}
@CacheableTask
abstract class CheckBadgingTask : DefaultTask() {
// In order for the task to be up-to-date when the inputs have not changed,
// the task must declare an output, even if it's not used. Tasks with no
// output are always run regardless of whether the inputs changed
@get:OutputDirectory
abstract val output: DirectoryProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val goldenBadging: RegularFileProperty
@get:PathSensitive(PathSensitivity.NONE)
@get:InputFile
abstract val generatedBadging: RegularFileProperty
@get:Input
abstract val updateBadgingTaskName: Property<String>
override fun getGroup(): String = LifecycleBasePlugin.VERIFICATION_GROUP
@TaskAction
fun taskAction() {
assertWithMessage(
"Generated badging is different from golden badging! " +
"If this change is intended, run ./gradlew ${updateBadgingTaskName.get()}",
)
.that(generatedBadging.get().asFile.readText())
.isEqualTo(goldenBadging.get().asFile.readText())
}
}
fun Project.configureBadgingTasks(
baseExtension: BaseExtension,
componentsExtension: ApplicationAndroidComponentsExtension,
) {
// Registers a callback to be called, when a new variant is configured
componentsExtension.onVariants { variant ->
// Registers a new task to verify the app bundle.
val capitalizedVariantName = variant.name.capitalized()
val generateBadgingTaskName = "generate${capitalizedVariantName}Badging"
val generateBadging =
tasks.register<GenerateBadgingTask>(generateBadgingTaskName) {
apk.set(
variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE),
)
aapt2Executable.set(
File(
baseExtension.sdkDirectory,
"${SdkConstants.FD_BUILD_TOOLS}/" +
"${baseExtension.buildToolsVersion}/" +
SdkConstants.FN_AAPT2,
),
)
badging.set(
project.layout.buildDirectory.file(
"outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt",
),
)
}
val updateBadgingTaskName = "update${capitalizedVariantName}Badging"
tasks.register<Copy>(updateBadgingTaskName) {
from(generateBadging.get().badging)
into(project.layout.projectDirectory)
}
val checkBadgingTaskName = "check${capitalizedVariantName}Badging"
tasks.register<CheckBadgingTask>(checkBadgingTaskName) {
goldenBadging.set(
project.layout.projectDirectory.file("${variant.name}-badging.txt"),
)
generatedBadging.set(
generateBadging.get().badging,
)
this.updateBadgingTaskName.set(updateBadgingTaskName)
output.set(
project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName"),
)
}
}
}

@ -50,7 +50,7 @@ internal fun Project.configureJacoco(
androidComponentsExtension.onVariants { variant -> androidComponentsExtension.onVariants { variant ->
val testTaskName = "test${variant.name.capitalize()}UnitTest" val testTaskName = "test${variant.name.capitalize()}UnitTest"
val buildDir = layout.buildDirectory.get().asFile
val reportTask = tasks.register("jacoco${testTaskName.capitalize()}Report", JacocoReport::class) { val reportTask = tasks.register("jacoco${testTaskName.capitalize()}Report", JacocoReport::class) {
dependsOn(testTaskName) dependsOn(testTaskName)

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

@ -32,8 +32,11 @@ 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.test) 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
@ -41,4 +44,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
} }

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.compose") alias(libs.plugins.nowinandroid.android.library.compose)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
} }
android { android {

@ -35,6 +35,8 @@ abstract class AnalyticsModule {
companion object { companion object {
@Provides @Provides
@Singleton @Singleton
fun provideFirebaseAnalytics(): FirebaseAnalytics { return Firebase.analytics } fun provideFirebaseAnalytics(): FirebaseAnalytics {
return Firebase.analytics
}
} }
} }

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.jacoco") alias(libs.plugins.nowinandroid.android.library.jacoco)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
} }
android { android {
@ -25,5 +25,5 @@ android {
dependencies { dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
testImplementation(project(":core:testing")) testImplementation(projects.core.testing)
} }

@ -14,8 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
} }
android { android {
@ -23,7 +23,7 @@ android {
} }
dependencies { dependencies {
api(project(":core:data")) api(projects.core.data)
implementation(project(":core:testing")) implementation(projects.core.testing)
implementation(project(":core:common")) implementation(projects.core.common)
} }

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.jacoco") alias(libs.plugins.nowinandroid.android.library.jacoco)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
id("kotlinx-serialization") id("kotlinx-serialization")
} }
@ -31,18 +31,18 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:analytics")) implementation(projects.core.analytics)
implementation(project(":core:common")) implementation(projects.core.common)
implementation(project(":core:database")) implementation(projects.core.database)
implementation(project(":core:datastore")) implementation(projects.core.datastore)
implementation(project(":core:model")) implementation(projects.core.model)
implementation(project(":core:network")) implementation(projects.core.network)
implementation(project(":core:notifications")) implementation(projects.core.notifications)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
testImplementation(project(":core:datastore-test")) testImplementation(projects.core.datastoreTest)
testImplementation(project(":core:testing")) testImplementation(projects.core.testing)
} }

@ -15,10 +15,10 @@
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.jacoco") alias(libs.plugins.nowinandroid.android.library.jacoco)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
id("nowinandroid.android.room") alias(libs.plugins.nowinandroid.android.room)
} }
android { android {
@ -30,10 +30,10 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:model")) implementation(projects.core.model)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
androidTestImplementation(project(":core:testing")) androidTestImplementation(projects.core.testing)
} }

@ -0,0 +1,55 @@
/*
* 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.
*/
plugins {
alias(libs.plugins.nowinandroid.android.library)
alias(libs.plugins.protobuf)
}
android {
namespace = "com.google.samples.apps.nowinandroid.core.datastore.proto"
}
// Setup protobuf configuration, generating lite Java and Kotlin classes
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
register("java") {
option("lite")
}
register("kotlin") {
option("lite")
}
}
}
}
}
androidComponents.beforeVariants {
android.sourceSets.register(it.name) {
val buildDir = layout.buildDirectory.get().asFile
java.srcDir(buildDir.resolve("generated/source/proto/${it.name}/java"))
kotlin.srcDir(buildDir.resolve("generated/source/proto/${it.name}/kotlin"))
}
}
dependencies {
implementation(libs.protobuf.kotlin.lite)
}

@ -14,8 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
} }
android { android {
@ -23,10 +23,10 @@ android {
} }
dependencies { dependencies {
api(project(":core:datastore")) api(projects.core.datastore)
api(libs.androidx.dataStore.core) api(libs.androidx.dataStore.core)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
implementation(project(":core:common")) implementation(projects.core.common)
implementation(project(":core:testing")) implementation(projects.core.testing)
} }

@ -15,10 +15,9 @@
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.jacoco") alias(libs.plugins.nowinandroid.android.library.jacoco)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
alias(libs.plugins.protobuf)
} }
android { android {
@ -33,39 +32,14 @@ android {
} }
} }
// Setup protobuf configuration, generating lite Java and Kotlin classes
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
register("java") {
option("lite")
}
register("kotlin") {
option("lite")
}
}
}
}
}
androidComponents.beforeVariants {
android.sourceSets.register(it.name) {
java.srcDir(buildDir.resolve("generated/source/proto/${it.name}/java"))
kotlin.srcDir(buildDir.resolve("generated/source/proto/${it.name}/kotlin"))
}
}
dependencies { dependencies {
implementation(project(":core:common")) api(projects.core.datastoreProto)
implementation(project(":core:model")) implementation(projects.core.common)
implementation(projects.core.model)
implementation(libs.androidx.dataStore.core) implementation(libs.androidx.dataStore.core)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.protobuf.kotlin.lite) implementation(libs.protobuf.kotlin.lite)
testImplementation(project(":core:datastore-test")) testImplementation(projects.core.datastoreTest)
testImplementation(project(":core:testing")) testImplementation(projects.core.testing)
} }

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.compose") alias(libs.plugins.nowinandroid.android.library.compose)
id("nowinandroid.android.library.jacoco") alias(libs.plugins.nowinandroid.android.library.jacoco)
} }
android { android {
@ -27,7 +27,7 @@ android {
} }
dependencies { dependencies {
lintPublish(project(":lint")) lintPublish(projects.lint)
api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation)
api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.foundation.layout)
@ -42,5 +42,5 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.coil.kt.compose) implementation(libs.coil.kt.compose)
androidTestImplementation(project(":core:testing")) androidTestImplementation(projects.core.testing)
} }

@ -134,7 +134,7 @@ fun NiaOutlinedButton(
MaterialTheme.colorScheme.outline MaterialTheme.colorScheme.outline
} else { } else {
MaterialTheme.colorScheme.onSurface.copy( MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaButtonDefaults.DisabledOutlinedButtonBorderAlpha, alpha = NiaButtonDefaults.DISABLED_OUTLINED_BUTTON_BORDER_ALPHA,
) )
}, },
), ),
@ -278,7 +278,7 @@ fun NiaButtonPreview() {
@ThemePreviews @ThemePreviews
@Composable @Composable
fun NiaOutlinedButtonPreview() { fun NiaOutlinedButtonPreview() {
NiaTheme() { NiaTheme {
NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) { NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) {
NiaOutlinedButton(onClick = {}, text = { Text("Test button") }) NiaOutlinedButton(onClick = {}, text = { Text("Test button") })
} }
@ -315,7 +315,7 @@ fun NiaButtonLeadingIconPreview() {
object NiaButtonDefaults { object NiaButtonDefaults {
// TODO: File bug // TODO: File bug
// OutlinedButton border color doesn't respect disabled state by default // OutlinedButton border color doesn't respect disabled state by default
const val DisabledOutlinedButtonBorderAlpha = 0.12f const val DISABLED_OUTLINED_BUTTON_BORDER_ALPHA = 0.12f
// TODO: File bug // TODO: File bug
// OutlinedButton default border width isn't exposed via ButtonDefaults // OutlinedButton default border width isn't exposed via ButtonDefaults

@ -76,10 +76,10 @@ fun NiaFilterChip(
borderColor = MaterialTheme.colorScheme.onBackground, borderColor = MaterialTheme.colorScheme.onBackground,
selectedBorderColor = MaterialTheme.colorScheme.onBackground, selectedBorderColor = MaterialTheme.colorScheme.onBackground,
disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy( disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha, alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,
), ),
disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy( disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha, alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,
), ),
selectedBorderWidth = NiaChipDefaults.ChipBorderWidth, selectedBorderWidth = NiaChipDefaults.ChipBorderWidth,
), ),
@ -88,16 +88,16 @@ fun NiaFilterChip(
iconColor = MaterialTheme.colorScheme.onBackground, iconColor = MaterialTheme.colorScheme.onBackground,
disabledContainerColor = if (selected) { disabledContainerColor = if (selected) {
MaterialTheme.colorScheme.onBackground.copy( MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContainerAlpha, alpha = NiaChipDefaults.DISABLED_CHIP_CONTAINER_ALPHA,
) )
} else { } else {
Color.Transparent Color.Transparent
}, },
disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy( disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha, alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,
), ),
disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy( disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaChipDefaults.DisabledChipContentAlpha, alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA,
), ),
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onBackground, selectedLabelColor = MaterialTheme.colorScheme.onBackground,
@ -124,7 +124,7 @@ fun ChipPreview() {
object NiaChipDefaults { object NiaChipDefaults {
// TODO: File bug // TODO: File bug
// FilterChip default values aren't exposed via FilterChipDefaults // FilterChip default values aren't exposed via FilterChipDefaults
const val DisabledChipContainerAlpha = 0.12f const val DISABLED_CHIP_CONTAINER_ALPHA = 0.12f
const val DisabledChipContentAlpha = 0.38f const val DISABLED_CHIP_CONTENT_ALPHA = 0.38f
val ChipBorderWidth = 1.dp val ChipBorderWidth = 1.dp
} }

@ -49,7 +49,7 @@ fun DynamicAsyncImage(
imageUrl: String, imageUrl: String,
contentDescription: String?, contentDescription: String?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeholder: Painter = painterResource(R.drawable.ic_placeholder_default), placeholder: Painter = painterResource(R.drawable.core_designsystem_ic_placeholder_default),
) { ) {
val iconTint = LocalTintTheme.current.iconTint val iconTint = LocalTintTheme.current.iconTint
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }

@ -60,7 +60,7 @@ fun NiaIconToggleButton(
checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
disabledContainerColor = if (checked) { disabledContainerColor = if (checked) {
MaterialTheme.colorScheme.onBackground.copy( MaterialTheme.colorScheme.onBackground.copy(
alpha = NiaIconButtonDefaults.DisabledIconButtonContainerAlpha, alpha = NiaIconButtonDefaults.DISABLED_ICON_BUTTON_CONTAINER_ALPHA,
) )
} else { } else {
Color.Transparent Color.Transparent
@ -123,5 +123,5 @@ fun IconButtonPreviewUnchecked() {
object NiaIconButtonDefaults { object NiaIconButtonDefaults {
// TODO: File bug // TODO: File bug
// IconToggleButton disabled container alpha not exposed by IconButtonDefaults // IconToggleButton disabled container alpha not exposed by IconButtonDefaults
const val DisabledIconButtonContainerAlpha = 0.12f const val DISABLED_ICON_BUTTON_CONTAINER_ALPHA = 0.12f
} }

@ -40,7 +40,7 @@ fun NiaTopicTag(
MaterialTheme.colorScheme.primaryContainer MaterialTheme.colorScheme.primaryContainer
} else { } else {
MaterialTheme.colorScheme.surfaceVariant.copy( MaterialTheme.colorScheme.surfaceVariant.copy(
alpha = NiaTagDefaults.UnfollowedTopicTagContainerAlpha, alpha = NiaTagDefaults.UNFOLLOWED_TOPIC_TAG_CONTAINER_ALPHA,
) )
} }
TextButton( TextButton(
@ -50,7 +50,7 @@ fun NiaTopicTag(
containerColor = containerColor, containerColor = containerColor,
contentColor = contentColorFor(backgroundColor = containerColor), contentColor = contentColorFor(backgroundColor = containerColor),
disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy( disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(
alpha = NiaTagDefaults.DisabledTopicTagContainerAlpha, alpha = NiaTagDefaults.DISABLED_TOPIC_TAG_CONTAINER_ALPHA,
), ),
), ),
) { ) {
@ -75,9 +75,9 @@ fun TagPreview() {
* Now in Android tag default values. * Now in Android tag default values.
*/ */
object NiaTagDefaults { object NiaTagDefaults {
const val UnfollowedTopicTagContainerAlpha = 0.5f const val UNFOLLOWED_TOPIC_TAG_CONTAINER_ALPHA = 0.5f
// TODO: File bug // TODO: File bug
// Button disabled container alpha value not exposed by ButtonDefaults // Button disabled container alpha value not exposed by ButtonDefaults
const val DisabledTopicTagContainerAlpha = 0.12f const val DISABLED_TOPIC_TAG_CONTAINER_ALPHA = 0.12f
} }

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import android.annotation.SuppressLint
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.SpringSpec
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.Orientation.Horizontal import androidx.compose.foundation.gestures.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical import androidx.compose.foundation.gestures.Orientation.Vertical
@ -38,12 +38,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorProducer
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active
import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Dormant import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Dormant
@ -130,12 +140,7 @@ private fun ScrollableState.DraggableScrollbarThumb(
Horizontal -> height(12.dp).fillMaxWidth() Horizontal -> height(12.dp).fillMaxWidth()
} }
} }
.background( .scrollThumb(this, interactionSource),
color = scrollbarThumbColor(
interactionSource = interactionSource,
),
shape = RoundedCornerShape(16.dp),
),
) )
} }
@ -155,31 +160,72 @@ private fun ScrollableState.DecorativeScrollbarThumb(
Horizontal -> height(2.dp).fillMaxWidth() Horizontal -> height(2.dp).fillMaxWidth()
} }
} }
.background( .scrollThumb(this, interactionSource),
color = scrollbarThumbColor(
interactionSource = interactionSource,
),
shape = RoundedCornerShape(16.dp),
),
) )
} }
// TODO: This lint is removed in 1.6 as the recommendation has changed
// remove when project is upgraded
@SuppressLint("ComposableModifierFactory")
@Composable
private fun Modifier.scrollThumb(
scrollableState: ScrollableState,
interactionSource: InteractionSource,
): Modifier {
val colorState = scrollbarThumbColor(scrollableState, interactionSource)
return this then ScrollThumbElement { colorState.value }
}
private data class ScrollThumbElement(val colorProducer: ColorProducer) :
ModifierNodeElement<ScrollThumbNode>() {
override fun create(): ScrollThumbNode = ScrollThumbNode(colorProducer)
override fun update(node: ScrollThumbNode) {
node.colorProducer = colorProducer
node.invalidateDraw()
}
}
private class ScrollThumbNode(var colorProducer: ColorProducer) : DrawModifierNode, Modifier.Node() {
private val shape = RoundedCornerShape(16.dp)
// naive cache outline calculation if size is the same
private var lastSize: Size? = null
private var lastLayoutDirection: LayoutDirection? = null
private var lastOutline: Outline? = null
override fun ContentDrawScope.draw() {
val color = colorProducer()
val outline =
if (size == lastSize && layoutDirection == lastLayoutDirection) {
lastOutline!!
} else {
shape.createOutline(size, layoutDirection, this)
}
if (color != Color.Unspecified) drawOutline(outline, color = color)
lastOutline = outline
lastSize = size
lastLayoutDirection = layoutDirection
}
}
/** /**
* The color of the scrollbar thumb as a function of its interaction state. * The color of the scrollbar thumb as a function of its interaction state.
* @param interactionSource source of interactions in the scrolling container * @param interactionSource source of interactions in the scrolling container
*/ */
@Composable @Composable
private fun ScrollableState.scrollbarThumbColor( private fun scrollbarThumbColor(
scrollableState: ScrollableState,
interactionSource: InteractionSource, interactionSource: InteractionSource,
): Color { ): State<Color> {
var state by remember { mutableStateOf(Dormant) } var state by remember { mutableStateOf(Dormant) }
val pressed by interactionSource.collectIsPressedAsState() val pressed by interactionSource.collectIsPressedAsState()
val hovered by interactionSource.collectIsHoveredAsState() val hovered by interactionSource.collectIsHoveredAsState()
val dragged by interactionSource.collectIsDraggedAsState() val dragged by interactionSource.collectIsDraggedAsState()
val active = (canScrollForward || canScrollForward) && val active = (scrollableState.canScrollForward || scrollableState.canScrollBackward) &&
(pressed || hovered || dragged || isScrollInProgress) (pressed || hovered || dragged || scrollableState.isScrollInProgress)
val color by animateColorAsState( val color = animateColorAsState(
targetValue = when (state) { targetValue = when (state) {
Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f) Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f)
Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f)
@ -205,5 +251,7 @@ private fun ScrollableState.scrollbarThumbColor(
} }
private enum class ThumbState { private enum class ThumbState {
Active, Inactive, Dormant Active,
Inactive,
Dormant,
} }

@ -17,79 +17,7 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.min
/**
* Calculates the [ScrollbarState] for lazy layouts.
* @param itemsAvailable the total amount of items available to scroll in the layout.
* @param visibleItems a list of items currently visible in the layout.
* @param firstVisibleItemIndex a function for interpolating the first visible index in the lazy layout
* as scrolling progresses for smooth and linear scrollbar thumb progression.
* [itemsAvailable].
* @param reverseLayout if the items in the backing lazy layout are laid out in reverse order.
* */
@Composable
internal inline fun <LazyState : ScrollableState, LazyStateItem> LazyState.scrollbarState(
itemsAvailable: Int,
crossinline visibleItems: LazyState.() -> List<LazyStateItem>,
crossinline firstVisibleItemIndex: LazyState.(List<LazyStateItem>) -> Float,
crossinline itemPercentVisible: LazyState.(LazyStateItem) -> Float,
crossinline reverseLayout: LazyState.() -> Boolean,
): ScrollbarState {
var state by remember { mutableStateOf(ScrollbarState.FULL) }
LaunchedEffect(
key1 = this,
key2 = itemsAvailable,
) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = visibleItems(this@scrollbarState)
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = firstVisibleItemIndex(visibleItemsInfo),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.sumOf {
itemPercentVisible(it).toDouble()
}.toFloat()
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
ScrollbarState(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
reverseLayout() -> 1f - thumbTravelPercent
else -> thumbTravelPercent
},
)
}
.filterNotNull()
.distinctUntilChanged()
.collect { state = it }
}
return state
}
/** /**
* Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar * Linearly interpolates the index for the first item in [visibleItems] for smooth scrollbar

@ -16,8 +16,9 @@
package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.Orientation.Horizontal
import androidx.compose.foundation.gestures.Orientation.Vertical
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures
@ -28,30 +29,28 @@ import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.util.packFloats import androidx.compose.ui.util.packFloats
import androidx.compose.ui.util.unpackFloat1 import androidx.compose.ui.util.unpackFloat1
import androidx.compose.ui.util.unpackFloat2 import androidx.compose.ui.util.unpackFloat2
@ -60,6 +59,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt
/** /**
* The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll * The delay between scrolls when a user long presses on the scrollbar track to initiate a scroll
@ -73,21 +73,59 @@ private const val SCROLLBAR_PRESS_DELAY_MS = 10L
*/ */
private const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f private const val SCROLLBAR_PRESS_DELTA_PCT = 0.02f
class ScrollbarState {
private var packedValue by mutableLongStateOf(0L)
internal fun onScroll(stateValue: ScrollbarStateValue) {
packedValue = stateValue.packedValue
}
/**
* Returns the thumb size of the scrollbar as a percentage of the total track size
*/
val thumbSizePercent
get() = unpackFloat1(packedValue)
/**
* Returns the distance the thumb has traveled as a percentage of total track size
*/
val thumbMovedPercent
get() = unpackFloat2(packedValue)
/**
* Returns the max distance the thumb can travel as a percentage of total track size
*/
val thumbTrackSizePercent
get() = 1f - thumbSizePercent
}
/**
* Returns the size of the scrollbar track in pixels
*/
private val ScrollbarTrack.size
get() = unpackFloat2(packedValue) - unpackFloat1(packedValue)
/**
* Returns the position of the scrollbar thumb on the track as a percentage
*/
private fun ScrollbarTrack.thumbPosition(
dimension: Float,
): Float = max(
a = min(
a = dimension / size,
b = 1f,
),
b = 0f,
)
/** /**
* Class definition for the core properties of a scroll bar * Class definition for the core properties of a scroll bar
*/ */
@Immutable @Immutable
@JvmInline @JvmInline
value class ScrollbarState internal constructor( value class ScrollbarStateValue internal constructor(
internal val packedValue: Long, internal val packedValue: Long,
) { )
companion object {
val FULL = ScrollbarState(
thumbSizePercent = 1f,
thumbMovedPercent = 0f,
)
}
}
/** /**
* Class definition for the core properties of a scroll bar track * Class definition for the core properties of a scroll bar track
@ -104,54 +142,23 @@ private value class ScrollbarTrack(
} }
/** /**
* Creates a [ScrollbarState] with the listed properties * Creates a [ScrollbarStateValue] with the listed properties
* @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size. * @param thumbSizePercent the thumb size of the scrollbar as a percentage of the total track size.
* Refers to either the thumb width (for horizontal scrollbars) * Refers to either the thumb width (for horizontal scrollbars)
* or height (for vertical scrollbars). * or height (for vertical scrollbars).
* @param thumbMovedPercent the distance the thumb has traveled as a percentage of total * @param thumbMovedPercent the distance the thumb has traveled as a percentage of total
* track size. * track size.
*/ */
fun ScrollbarState( fun scrollbarStateValue(
thumbSizePercent: Float, thumbSizePercent: Float,
thumbMovedPercent: Float, thumbMovedPercent: Float,
) = ScrollbarState( ) = ScrollbarStateValue(
packFloats( packFloats(
val1 = thumbSizePercent, val1 = thumbSizePercent,
val2 = thumbMovedPercent, val2 = thumbMovedPercent,
), ),
) )
/**
* Returns the thumb size of the scrollbar as a percentage of the total track size
*/
val ScrollbarState.thumbSizePercent
get() = unpackFloat1(packedValue)
/**
* Returns the distance the thumb has traveled as a percentage of total track size
*/
val ScrollbarState.thumbMovedPercent
get() = unpackFloat2(packedValue)
/**
* Returns the size of the scrollbar track in pixels
*/
private val ScrollbarTrack.size
get() = unpackFloat2(packedValue) - unpackFloat1(packedValue)
/**
* Returns the position of the scrollbar thumb on the track as a percentage
*/
private fun ScrollbarTrack.thumbPosition(
dimension: Float,
): Float = max(
a = min(
a = dimension / size,
b = 1f,
),
b = 0f,
)
/** /**
* Returns the value of [offset] along the axis specified by [this] * Returns the value of [offset] along the axis specified by [this]
*/ */
@ -196,8 +203,6 @@ fun Scrollbar(
thumb: @Composable () -> Unit, thumb: @Composable () -> Unit,
onThumbMoved: ((Float) -> Unit)? = null, onThumbMoved: ((Float) -> Unit)? = null,
) { ) {
val localDensity = LocalDensity.current
// Using Offset.Unspecified and Float.NaN instead of null // Using Offset.Unspecified and Float.NaN instead of null
// to prevent unnecessary boxing of primitives // to prevent unnecessary boxing of primitives
var pressedOffset by remember { mutableStateOf(Offset.Unspecified) } var pressedOffset by remember { mutableStateOf(Offset.Unspecified) }
@ -205,27 +210,10 @@ fun Scrollbar(
// Used to immediately show drag feedback in the UI while the scrolling implementation // Used to immediately show drag feedback in the UI while the scrolling implementation
// catches up // catches up
var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) } var interactionThumbTravelPercent by remember { mutableFloatStateOf(Float.NaN) }
var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) } var track by remember { mutableStateOf(ScrollbarTrack(packedValue = 0)) }
val thumbTravelPercent = when {
interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent
else -> interactionThumbTravelPercent
}
val thumbSizePx = max(
a = state.thumbSizePercent * track.size,
b = with(localDensity) { minThumbSize.toPx() },
)
val thumbSizeDp by animateDpAsState(
targetValue = with(localDensity) { thumbSizePx.toDp() },
label = "scrollbar thumb size",
)
val thumbMovedPx = min(
a = track.size * thumbTravelPercent,
b = track.size - thumbSizePx,
)
// scrollbar track container // scrollbar track container
Box( Box(
modifier = modifier modifier = modifier
@ -319,84 +307,113 @@ fun Scrollbar(
} }
}, },
) { ) {
val scrollbarThumbMovedDp = max(
a = with(localDensity) { thumbMovedPx.toDp() },
b = 0.dp,
)
// scrollbar thumb container // scrollbar thumb container
Box( Layout(content = { thumb() }) { measurables, constraints ->
modifier = Modifier val measurable = measurables.first()
.align(Alignment.TopStart)
.run { val thumbSizePx = max(
when (orientation) { a = state.thumbSizePercent * track.size,
Orientation.Horizontal -> width(thumbSizeDp) b = minThumbSize.toPx(),
Orientation.Vertical -> height(thumbSizeDp) )
}
} val trackSizePx = when (state.thumbTrackSizePercent) {
.offset( 0f -> track.size
y = when (orientation) { else -> (track.size - thumbSizePx) / state.thumbTrackSizePercent
Orientation.Horizontal -> 0.dp }
Orientation.Vertical -> scrollbarThumbMovedDp
}, val thumbTravelPercent = max(
x = when (orientation) { a = min(
Orientation.Horizontal -> scrollbarThumbMovedDp a = when {
Orientation.Vertical -> 0.dp interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent
else -> interactionThumbTravelPercent
}, },
b = state.thumbTrackSizePercent,
), ),
) { b = 0f,
thumb() )
val thumbMovedPx = trackSizePx * thumbTravelPercent
val y = when (orientation) {
Horizontal -> 0
Vertical -> thumbMovedPx.roundToInt()
}
val x = when (orientation) {
Horizontal -> thumbMovedPx.roundToInt()
Vertical -> 0
}
val updatedConstraints = when (orientation) {
Horizontal -> {
constraints.copy(
minWidth = thumbSizePx.roundToInt(),
maxWidth = thumbSizePx.roundToInt(),
)
}
Vertical -> {
constraints.copy(
minHeight = thumbSizePx.roundToInt(),
maxHeight = thumbSizePx.roundToInt(),
)
}
}
val placeable = measurable.measure(updatedConstraints)
layout(placeable.width, placeable.height) {
placeable.place(x, y)
}
} }
} }
if (onThumbMoved == null) return if (onThumbMoved == null) return
// State that will be read inside the effects that follow
// but will not cause re-triggering of them
val updatedState by rememberUpdatedState(state)
// Process presses // Process presses
LaunchedEffect(pressedOffset) { LaunchedEffect(Unit) {
// Press ended, reset interactionThumbTravelPercent snapshotFlow { pressedOffset }.collect { pressedOffset ->
if (pressedOffset == Offset.Unspecified) { // Press ended, reset interactionThumbTravelPercent
interactionThumbTravelPercent = Float.NaN if (pressedOffset == Offset.Unspecified) {
return@LaunchedEffect interactionThumbTravelPercent = Float.NaN
} return@collect
}
var currentThumbMovedPercent = updatedState.thumbMovedPercent var currentThumbMovedPercent = state.thumbMovedPercent
val destinationThumbMovedPercent = track.thumbPosition( val destinationThumbMovedPercent = track.thumbPosition(
dimension = orientation.valueOf(pressedOffset), dimension = orientation.valueOf(pressedOffset),
) )
val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent
val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f
while (currentThumbMovedPercent != destinationThumbMovedPercent) { while (currentThumbMovedPercent != destinationThumbMovedPercent) {
currentThumbMovedPercent = when { currentThumbMovedPercent = when {
isPositive -> min( isPositive -> min(
a = currentThumbMovedPercent + delta, a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent, b = destinationThumbMovedPercent,
) )
else -> max( else -> max(
a = currentThumbMovedPercent + delta, a = currentThumbMovedPercent + delta,
b = destinationThumbMovedPercent, b = destinationThumbMovedPercent,
) )
}
onThumbMoved(currentThumbMovedPercent)
interactionThumbTravelPercent = currentThumbMovedPercent
delay(SCROLLBAR_PRESS_DELAY_MS)
} }
onThumbMoved(currentThumbMovedPercent)
interactionThumbTravelPercent = currentThumbMovedPercent
delay(SCROLLBAR_PRESS_DELAY_MS)
} }
} }
// Process drags // Process drags
LaunchedEffect(draggedOffset) { LaunchedEffect(Unit) {
if (draggedOffset == Offset.Unspecified) { snapshotFlow { draggedOffset }.collect { draggedOffset ->
interactionThumbTravelPercent = Float.NaN if (draggedOffset == Offset.Unspecified) {
return@LaunchedEffect interactionThumbTravelPercent = Float.NaN
return@collect
}
val currentTravel = track.thumbPosition(
dimension = orientation.valueOf(draggedOffset),
)
onThumbMoved(currentTravel)
interactionThumbTravelPercent = currentTravel
} }
val currentTravel = track.thumbPosition(
dimension = orientation.valueOf(draggedOffset),
)
onThumbMoved(currentTravel)
interactionThumbTravelPercent = currentTravel
} }
} }

@ -1,5 +1,5 @@
/* /*
* Copyright 2021 The Android Open Source Project * Copyright 2023 The Android Open Source Project
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -21,7 +21,15 @@ import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlin.math.min
/** /**
* Calculates a [ScrollbarState] driven by the changes in a [LazyListState]. * Calculates a [ScrollbarState] driven by the changes in a [LazyListState].
@ -33,29 +41,58 @@ import androidx.compose.runtime.Composable
fun LazyListState.scrollbarState( fun LazyListState.scrollbarState(
itemsAvailable: Int, itemsAvailable: Int,
itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index, itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index,
): ScrollbarState = ): ScrollbarState {
scrollbarState( val state = remember { ScrollbarState() }
itemsAvailable = itemsAvailable, LaunchedEffect(this, itemsAvailable) {
visibleItems = { layoutInfo.visibleItemsInfo }, snapshotFlow {
firstVisibleItemIndex = { visibleItems -> if (itemsAvailable == 0) return@snapshotFlow null
interpolateFirstItemIndex(
visibleItems = visibleItems, val visibleItemsInfo = layoutInfo.visibleItemsInfo
itemSize = { it.size }, if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
offset = { it.offset },
nextItemOnMainAxis = { first -> visibleItems.find { it != first } }, val firstIndex = min(
itemIndex = itemIndex, a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { it.size },
offset = { it.offset },
nextItemOnMainAxis = { first -> visibleItemsInfo.find { it != first } },
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = itemInfo.size,
itemStartOffset = itemInfo.offset,
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
) )
}, scrollbarStateValue(
itemPercentVisible = itemPercentVisible@{ itemInfo -> thumbSizePercent = thumbSizePercent,
itemVisibilityPercentage( thumbMovedPercent = when {
itemSize = itemInfo.size, layoutInfo.reverseLayout -> 1f - thumbTravelPercent
itemStartOffset = itemInfo.offset, else -> thumbTravelPercent
viewportStartOffset = layoutInfo.viewportStartOffset, },
viewportEndOffset = layoutInfo.viewportEndOffset,
) )
}, }
reverseLayout = { layoutInfo.reverseLayout }, .filterNotNull()
) .distinctUntilChanged()
.collect { state.onScroll(it) }
}
return state
}
/** /**
* Calculates a [ScrollbarState] driven by the changes in a [LazyGridState] * Calculates a [ScrollbarState] driven by the changes in a [LazyGridState]
@ -67,38 +104,136 @@ fun LazyListState.scrollbarState(
fun LazyGridState.scrollbarState( fun LazyGridState.scrollbarState(
itemsAvailable: Int, itemsAvailable: Int,
itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index, itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index,
): ScrollbarState = ): ScrollbarState {
scrollbarState( val state = remember { ScrollbarState() }
itemsAvailable = itemsAvailable, LaunchedEffect(this, itemsAvailable) {
visibleItems = { layoutInfo.visibleItemsInfo }, snapshotFlow {
firstVisibleItemIndex = { visibleItems -> if (itemsAvailable == 0) return@snapshotFlow null
interpolateFirstItemIndex(
visibleItems = visibleItems, val visibleItemsInfo = layoutInfo.visibleItemsInfo
itemSize = { if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
layoutInfo.orientation.valueOf(it.size)
},
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
when (layoutInfo.orientation) {
Orientation.Vertical -> visibleItems.find {
it != first && it.row != first.row
}
Orientation.Horizontal -> visibleItems.find { val firstIndex = min(
it != first && it.column != first.column a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { layoutInfo.orientation.valueOf(it.size) },
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
when (layoutInfo.orientation) {
Orientation.Vertical -> visibleItemsInfo.find {
it != first && it.row != first.row
}
Orientation.Horizontal -> visibleItemsInfo.find {
it != first && it.column != first.column
}
} }
} },
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
)
val thumbSizePercent = min(
a = itemsVisible / itemsAvailable,
b = 1f,
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = when {
layoutInfo.reverseLayout -> 1f - thumbTravelPercent
else -> thumbTravelPercent
}, },
itemIndex = itemIndex,
) )
}, }
itemPercentVisible = itemPercentVisible@{ itemInfo -> .filterNotNull()
itemVisibilityPercentage( .distinctUntilChanged()
itemSize = layoutInfo.orientation.valueOf(itemInfo.size), .collect { state.onScroll(it) }
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset), }
viewportStartOffset = layoutInfo.viewportStartOffset, return state
viewportEndOffset = layoutInfo.viewportEndOffset, }
/**
* Remembers a [ScrollbarState] driven by the changes in a [LazyStaggeredGridState]
*
* @param itemsAvailable the total amount of items available to scroll in the staggered grid.
* @param itemIndex a lookup function for index of an item in the staggered grid relative
* to [itemsAvailable].
*/
@Composable
fun LazyStaggeredGridState.scrollbarState(
itemsAvailable: Int,
itemIndex: (LazyStaggeredGridItemInfo) -> Int = LazyStaggeredGridItemInfo::index,
): ScrollbarState {
val state = remember { ScrollbarState() }
LaunchedEffect(this, itemsAvailable) {
snapshotFlow {
if (itemsAvailable == 0) return@snapshotFlow null
val visibleItemsInfo = layoutInfo.visibleItemsInfo
if (visibleItemsInfo.isEmpty()) return@snapshotFlow null
val firstIndex = min(
a = interpolateFirstItemIndex(
visibleItems = visibleItemsInfo,
itemSize = { layoutInfo.orientation.valueOf(it.size) },
offset = { layoutInfo.orientation.valueOf(it.offset) },
nextItemOnMainAxis = { first ->
visibleItemsInfo.find { it != first && it.lane == first.lane }
},
itemIndex = itemIndex,
),
b = itemsAvailable.toFloat(),
)
if (firstIndex.isNaN()) return@snapshotFlow null
val itemsVisible = visibleItemsInfo.floatSumOf { itemInfo ->
itemVisibilityPercentage(
itemSize = layoutInfo.orientation.valueOf(itemInfo.size),
itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset),
viewportStartOffset = layoutInfo.viewportStartOffset,
viewportEndOffset = layoutInfo.viewportEndOffset,
)
}
val thumbTravelPercent = min(
a = firstIndex / itemsAvailable,
b = 1f,
) )
}, val thumbSizePercent = min(
reverseLayout = { layoutInfo.reverseLayout }, a = itemsVisible / itemsAvailable,
) b = 1f,
)
scrollbarStateValue(
thumbSizePercent = thumbSizePercent,
thumbMovedPercent = thumbTravelPercent,
)
}
.filterNotNull()
.distinctUntilChanged()
.collect { state.onScroll(it) }
}
return state
}
private inline fun <T> List<T>.floatSumOf(selector: (T) -> Float): Float {
var sum = 0f
for (element in this) {
sum += selector(element)
}
return sum
}

@ -18,13 +18,15 @@ package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollb
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
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
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import kotlin.math.roundToInt
/** /**
* Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState] * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState]
@ -50,6 +52,19 @@ fun LazyGridState.rememberDraggableScroller(
scroll = ::scrollToItem, scroll = ::scrollToItem,
) )
/**
* Remembers a function to react to [Scrollbar] thumb position displacements for a
* [LazyStaggeredGridState]
* @param itemsAvailable the amount of items in the staggered grid.
*/
@Composable
fun LazyStaggeredGridState.rememberDraggableScroller(
itemsAvailable: Int,
): (Float) -> Unit = rememberDraggableScroller(
itemsAvailable = itemsAvailable,
scroll = ::scrollToItem,
)
/** /**
* Generic function to react to [Scrollbar] thumb displacements in a lazy layout. * Generic function to react to [Scrollbar] thumb displacements in a lazy layout.
* @param itemsAvailable the total amount of items available to scroll in the layout. * @param itemsAvailable the total amount of items available to scroll in the layout.
@ -60,12 +75,12 @@ private inline fun rememberDraggableScroller(
itemsAvailable: Int, itemsAvailable: Int,
crossinline scroll: suspend (index: Int) -> Unit, crossinline scroll: suspend (index: Int) -> Unit,
): (Float) -> Unit { ): (Float) -> Unit {
var percentage by remember { mutableStateOf(Float.NaN) } var percentage by remember { mutableFloatStateOf(Float.NaN) }
val itemCount by rememberUpdatedState(itemsAvailable) val itemCount by rememberUpdatedState(itemsAvailable)
LaunchedEffect(percentage) { LaunchedEffect(percentage) {
if (percentage.isNaN()) return@LaunchedEffect if (percentage.isNaN()) return@LaunchedEffect
val indexToFind = (itemCount * percentage).toInt() val indexToFind = (itemCount * percentage).roundToInt()
scroll(indexToFind) scroll(indexToFind)
} }
return remember { return remember {

@ -36,7 +36,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class BackgroundScreenshotTests { class BackgroundScreenshotTests {

@ -36,7 +36,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class ButtonScreenshotTests { class ButtonScreenshotTests {

@ -43,7 +43,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class FilterChipScreenshotTests() { class FilterChipScreenshotTests() {
@ -52,7 +52,7 @@ class FilterChipScreenshotTests() {
@Test @Test
fun filterChip_multipleThemes() { fun filterChip_multipleThemes() {
composeTestRule.captureMultiTheme("FilterChip") { description -> composeTestRule.captureMultiTheme("FilterChip") {
Surface { Surface {
NiaFilterChip(selected = false, onSelectedChange = {}) { NiaFilterChip(selected = false, onSelectedChange = {}) {
Text("Unselected chip") Text("Unselected chip")
@ -63,7 +63,7 @@ class FilterChipScreenshotTests() {
@Test @Test
fun filterChip_multipleThemes_selected() { fun filterChip_multipleThemes_selected() {
composeTestRule.captureMultiTheme("FilterChip", "FilterChipSelected") { description -> composeTestRule.captureMultiTheme("FilterChip", "FilterChipSelected") {
Surface { Surface {
NiaFilterChip(selected = true, onSelectedChange = {}) { NiaFilterChip(selected = true, onSelectedChange = {}) {
Text("Selected Chip") Text("Selected Chip")

@ -35,7 +35,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class IconButtonScreenshotTests { class IconButtonScreenshotTests {
@ -44,14 +44,14 @@ class IconButtonScreenshotTests {
@Test @Test
fun iconButton_multipleThemes() { fun iconButton_multipleThemes() {
composeTestRule.captureMultiTheme("IconButton") { description -> composeTestRule.captureMultiTheme("IconButton") {
NiaIconToggleExample(false) NiaIconToggleExample(false)
} }
} }
@Test @Test
fun iconButton_unchecked_multipleThemes() { fun iconButton_unchecked_multipleThemes() {
composeTestRule.captureMultiTheme("IconButton", "IconButtonUnchecked") { description -> composeTestRule.captureMultiTheme("IconButton", "IconButtonUnchecked") {
Surface { Surface {
NiaIconToggleExample(true) NiaIconToggleExample(true)
} }

@ -37,7 +37,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class LoadingWheelScreenshotTests() { class LoadingWheelScreenshotTests() {
@ -66,7 +66,7 @@ class LoadingWheelScreenshotTests() {
fun loadingWheelAnimation() { fun loadingWheelAnimation() {
composeTestRule.mainClock.autoAdvance = false composeTestRule.mainClock.autoAdvance = false
composeTestRule.setContent { composeTestRule.setContent {
NiaTheme() { NiaTheme {
NiaLoadingWheel(contentDesc = "") NiaLoadingWheel(contentDesc = "")
} }
} }

@ -44,7 +44,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class NavigationScreenshotTests() { class NavigationScreenshotTests() {

@ -42,7 +42,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class TabsScreenshotTests() { class TabsScreenshotTests() {

@ -39,7 +39,7 @@ import org.robolectric.annotation.LooperMode
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class TagScreenshotTests() { class TagScreenshotTests() {

@ -43,7 +43,7 @@ import org.robolectric.annotation.LooperMode
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") @Config(application = HiltTestApplication::class, qualifiers = "480dpi")
@LooperMode(LooperMode.Mode.PAUSED) @LooperMode(LooperMode.Mode.PAUSED)
class TopAppBarScreenshotTests() { class TopAppBarScreenshotTests() {

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.jacoco") alias(libs.plugins.nowinandroid.android.library.jacoco)
kotlin("kapt") id("com.google.devtools.ksp")
} }
android { android {
@ -24,13 +24,13 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:data")) implementation(projects.core.data)
implementation(project(":core:model")) implementation(projects.core.model)
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
kapt(libs.hilt.compiler) ksp(libs.hilt.compiler)
testImplementation(project(":core:testing")) testImplementation(projects.core.testing)
} }

@ -15,7 +15,7 @@
*/ */
plugins { plugins {
id("nowinandroid.jvm.library") alias(libs.plugins.nowinandroid.jvm.library)
} }
dependencies { dependencies {

@ -17,5 +17,7 @@
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.model.data
enum class DarkThemeConfig { enum class DarkThemeConfig {
FOLLOW_SYSTEM, LIGHT, DARK FOLLOW_SYSTEM,
LIGHT,
DARK,
} }

@ -19,7 +19,8 @@ package com.google.samples.apps.nowinandroid.core.model.data
/** /**
* A [topic] with the additional information for whether or not it is followed. * A [topic] with the additional information for whether or not it is followed.
*/ */
data class FollowableTopic( // TODO consider changing to UserTopic and flattening // TODO consider changing to UserTopic and flattening
data class FollowableTopic(
val topic: Topic, val topic: Topic,
val isFollowed: Boolean, val isFollowed: Boolean,
) )

@ -17,5 +17,6 @@
package com.google.samples.apps.nowinandroid.core.model.data package com.google.samples.apps.nowinandroid.core.model.data
enum class ThemeBrand { enum class ThemeBrand {
DEFAULT, ANDROID DEFAULT,
ANDROID,
} }

@ -15,9 +15,9 @@
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.jacoco") alias(libs.plugins.nowinandroid.android.library.jacoco)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
id("kotlinx-serialization") id("kotlinx-serialization")
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
} }
@ -39,8 +39,8 @@ secrets {
} }
dependencies { dependencies {
implementation(project(":core:common")) implementation(projects.core.common)
implementation(project(":core:model")) implementation(projects.core.model)
implementation(libs.coil.kt) implementation(libs.coil.kt)
implementation(libs.coil.kt.svg) implementation(libs.coil.kt.svg)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
@ -50,5 +50,5 @@ dependencies {
implementation(libs.retrofit.core) implementation(libs.retrofit.core)
implementation(libs.retrofit.kotlin.serialization) implementation(libs.retrofit.kotlin.serialization)
testImplementation(project(":core:testing")) testImplementation(projects.core.testing)
} }

@ -28,5 +28,5 @@ import dagger.hilt.components.SingletonComponent
interface FlavoredNetworkModule { interface FlavoredNetworkModule {
@Binds @Binds
fun FakeNiaNetworkDataSource.binds(): NiaNetworkDataSource fun binds(impl: FakeNiaNetworkDataSource): NiaNetworkDataSource
} }

@ -28,5 +28,5 @@ import dagger.hilt.components.SingletonComponent
interface FlavoredNetworkModule { interface FlavoredNetworkModule {
@Binds @Binds
fun RetrofitNiaNetwork.binds(): NiaNetworkDataSource fun binds(impl: RetrofitNiaNetwork): NiaNetworkDataSource
} }

@ -44,10 +44,10 @@ class FakeNiaNetworkDataSourceTest {
) )
} }
@Suppress("ktlint:standard:max-line-length")
@Test @Test
fun testDeserializationOfTopics() = runTest(testDispatcher) { fun testDeserializationOfTopics() = runTest(testDispatcher) {
assertEquals( assertEquals(
/* ktlint-disable max-line-length */
NetworkTopic( NetworkTopic(
id = "1", id = "1",
name = "Headlines", name = "Headlines",
@ -56,15 +56,14 @@ class FakeNiaNetworkDataSourceTest {
url = "", url = "",
imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f",
), ),
/* ktlint-enable max-line-length */
subject.getTopics().first(), subject.getTopics().first(),
) )
} }
@Suppress("ktlint:standard:max-line-length")
@Test @Test
fun testDeserializationOfNewsResources() = runTest(testDispatcher) { fun testDeserializationOfNewsResources() = runTest(testDispatcher) {
assertEquals( assertEquals(
/* ktlint-disable max-line-length */
NetworkNewsResource( NetworkNewsResource(
id = "125", id = "125",
title = "Android Basics with Compose", title = "Android Basics with Compose",
@ -83,7 +82,6 @@ class FakeNiaNetworkDataSourceTest {
type = "Codelab", type = "Codelab",
topics = listOf("2", "3", "10"), topics = listOf("2", "3", "10"),
), ),
/* ktlint-enable max-line-length */
subject.getNewsResources().find { it.id == "125" }, subject.getNewsResources().find { it.id == "125" },
) )
} }

@ -14,9 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("nowinandroid.android.library") alias(libs.plugins.nowinandroid.android.library)
id("nowinandroid.android.library.compose") alias(libs.plugins.nowinandroid.android.library.compose)
id("nowinandroid.android.hilt") alias(libs.plugins.nowinandroid.android.hilt)
} }
android { android {
@ -24,8 +24,8 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:common")) implementation(projects.core.common)
implementation(project(":core:model")) implementation(projects.core.model)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.browser) implementation(libs.androidx.browser)

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

Loading…
Cancel
Save