diff --git a/.editorconfig b/.editorconfig index 83be1d5a0..7be3f8784 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,3 +4,4 @@ [*.{kt,kts}] ij_kotlin_allow_trailing_comma=true ij_kotlin_allow_trailing_comma_on_call_site=true +ktlint_function_naming_ignore_when_annotated_with=Composable, Test diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md deleted file mode 100644 index c38247d23..000000000 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ /dev/null @@ -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 # 🦕 diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties index bd989705d..dbafa68cd 100644 --- a/.github/ci-gradle.properties +++ b/.github/ci-gradle.properties @@ -19,7 +19,6 @@ org.gradle.parallel=true org.gradle.workers.max=2 kotlin.incremental=false -kotlin.compiler.execution.strategy=in-process # Controls KotlinOptions.allWarningsAsErrors. # This value used in CI and is currently set to false. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d77a706b3 --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..ed5fa237d --- /dev/null +++ b/.github/pull_request_template.md @@ -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 # + +**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). + + diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index f19341761..000000000 --- a/.github/renovate.json +++ /dev/null @@ -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" - } - ] -} diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 9a0dca106..2e6d11841 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -11,45 +11,8 @@ concurrency: cancel-in-progress: true jobs: - build: - runs-on: ubuntu-latest - 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: + test_and_apk: + name: "Local tests and APKs" runs-on: ubuntu-latest permissions: @@ -59,7 +22,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 @@ -68,7 +31,7 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: 17 @@ -76,6 +39,12 @@ jobs: - name: Setup Gradle 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) id: screenshotsverify continue-on-error: true @@ -96,7 +65,7 @@ jobs: ./gradlew recordRoborazziDemoDebug - 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' with: file_pattern: '*/*.png' @@ -106,11 +75,30 @@ jobs: # Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots. - name: Run local tests 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) if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-results path: '**/build/test-results/test*UnitTest/**.xml' @@ -120,13 +108,15 @@ jobs: - name: Upload lint reports (HTML) if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: lint-reports path: '**/build/reports/lint-results-*.html' + - name: Check badging + run: ./gradlew :app:checkProdReleaseBadging + androidTest: - needs: build runs-on: macOS-latest # enables hardware acceleration in the virtual machine timeout-minutes: 55 strategy: @@ -135,13 +125,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - 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 + uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: 17 @@ -149,8 +139,8 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@v2 - - name: Build AndroidTest apps - run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest --daemon + - name: Build projects before running emulator + run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@v2 @@ -164,49 +154,7 @@ jobs: - name: Upload test reports if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: test-reports-${{ matrix.api-level }} 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 diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 534e9d893..f4901b9e2 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -7,12 +7,12 @@ on: jobs: build: - runs-on: ubuntu-latest - timeout-minutes: 45 + runs-on: macos-latest + timeout-minutes: 120 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 @@ -21,14 +21,24 @@ jobs: run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: 17 - - name: Build app - run: ./gradlew :app:assembleDemoRelease + - name: Install GMD image for baseline profile generation + 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 id: create_release uses: actions/create-release@v1 diff --git a/.idea/copyright/The_Android_Open_Source_Project.xml b/.idea/copyright/The_Android_Open_Source_Project.xml index 74acd98d8..855f041e8 100644 --- a/.idea/copyright/The_Android_Open_Source_Project.xml +++ b/.idea/copyright/The_Android_Open_Source_Project.xml @@ -1,6 +1,6 @@ - - \ No newline at end of file + diff --git a/README.md b/README.md index b71427dfe..6f13f5de2 100644 --- a/README.md +++ b/README.md @@ -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](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 -in. +in, and be notified when new content is published which matches interests they are following. ## Screenshots @@ -109,12 +109,42 @@ Examples: manipulate the state of the `Test` repository and verify the resulting behavior, instead of 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 -of certain screens and components. To run these tests, run the `verifyRoborazziDemoDebug` or -`recordRoborazziDemoDebug` tasks. Note that screenshots are recorded on CI, using Linux, and other -platforms might generate slightly different images, making the tests fail. +**Note:** You should not run `./gradlew test` or `./gradlew connectedAndroidTest` as this will execute +tests against _all_ build variants which is both unecessary and will result in failures as only the +`demoDebug` variant is supported. No other variants have any tests (although this might change in future). + +## 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 The app was designed using [Material 3 guidelines](https://m3.material.io/). Learn more about the design process and diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index 1f9ac1e2a..e02f6bc0b 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -32,8 +32,8 @@ import com.google.samples.apps.nowinandroid.NiaFlavor * limitations under the License. */ plugins { - id("nowinandroid.android.application") - id("nowinandroid.android.application.compose") + alias(libs.plugins.nowinandroid.android.application) + alias(libs.plugins.nowinandroid.android.application.compose) } android { @@ -65,7 +65,11 @@ android { } dependencies { - implementation(project(":core:designsystem")) - implementation(project(":core:ui")) + implementation(projects.core.designsystem) + implementation(projects.core.ui) implementation(libs.androidx.activity.compose) } + +dependencyGuard { + configuration("releaseRuntimeClasspath") +} diff --git a/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt new file mode 100644 index 000000000..786b15d57 --- /dev/null +++ b/app-nia-catalog/dependencies/releaseRuntimeClasspath.txt @@ -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 diff --git a/app-nia-catalog/src/main/kotlin/com/google/samples/apps/niacatalog/ui/Catalog.kt b/app-nia-catalog/src/main/kotlin/com/google/samples/apps/niacatalog/ui/Catalog.kt index 2624262ad..02d4cf8d5 100644 --- a/app-nia-catalog/src/main/kotlin/com/google/samples/apps/niacatalog/ui/Catalog.kt +++ b/app-nia-catalog/src/main/kotlin/com/google/samples/apps/niacatalog/ui/Catalog.kt @@ -32,8 +32,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -170,13 +171,13 @@ fun NiaCatalog() { item { Text("Chips", Modifier.padding(top = 16.dp)) } item { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - var firstChecked by remember { mutableStateOf(false) } + var firstChecked by rememberSaveable { mutableStateOf(false) } NiaFilterChip( selected = firstChecked, onSelectedChange = { checked -> firstChecked = checked }, label = { Text(text = "Enabled") }, ) - var secondChecked by remember { mutableStateOf(true) } + var secondChecked by rememberSaveable { mutableStateOf(true) } NiaFilterChip( selected = secondChecked, onSelectedChange = { checked -> secondChecked = checked }, @@ -199,7 +200,7 @@ fun NiaCatalog() { item { Text("Icon buttons", Modifier.padding(top = 16.dp)) } item { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - var firstChecked by remember { mutableStateOf(false) } + var firstChecked by rememberSaveable { mutableStateOf(false) } NiaIconToggleButton( checked = firstChecked, onCheckedChange = { checked -> firstChecked = checked }, @@ -216,7 +217,7 @@ fun NiaCatalog() { ) }, ) - var secondChecked by remember { mutableStateOf(true) } + var secondChecked by rememberSaveable { mutableStateOf(true) } NiaIconToggleButton( checked = secondChecked, onCheckedChange = { checked -> secondChecked = checked }, @@ -272,14 +273,14 @@ fun NiaCatalog() { item { Text("View toggle", Modifier.padding(top = 16.dp)) } item { FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - var firstExpanded by remember { mutableStateOf(false) } + var firstExpanded by rememberSaveable { mutableStateOf(false) } NiaViewToggleButton( expanded = firstExpanded, onExpandedChange = { expanded -> firstExpanded = expanded }, compactText = { Text(text = "Compact view") }, expandedText = { Text(text = "Expanded view") }, ) - var secondExpanded by remember { mutableStateOf(true) } + var secondExpanded by rememberSaveable { mutableStateOf(true) } NiaViewToggleButton( expanded = secondExpanded, onExpandedChange = { expanded -> secondExpanded = expanded }, @@ -318,7 +319,7 @@ fun NiaCatalog() { } item { Text("Tabs", Modifier.padding(top = 16.dp)) } item { - var selectedTabIndex by remember { mutableStateOf(0) } + var selectedTabIndex by rememberSaveable { mutableIntStateOf(0) } val titles = listOf("Topics", "People") NiaTabRow(selectedTabIndex = selectedTabIndex) { titles.forEachIndexed { index, title -> @@ -332,7 +333,7 @@ fun NiaCatalog() { } item { Text("Navigation", Modifier.padding(top = 16.dp)) } item { - var selectedItem by remember { mutableStateOf(0) } + var selectedItem by rememberSaveable { mutableIntStateOf(0) } val items = listOf("For you", "Saved", "Interests") val icons = listOf( NiaIcons.UpcomingBorder, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1ea598d7..b003c4a54 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,14 +16,15 @@ import com.google.samples.apps.nowinandroid.NiaBuildType plugins { - id("nowinandroid.android.application") - id("nowinandroid.android.application.compose") - id("nowinandroid.android.application.flavors") - id("nowinandroid.android.application.jacoco") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.application) + alias(libs.plugins.nowinandroid.android.application.compose) + alias(libs.plugins.nowinandroid.android.application.flavors) + alias(libs.plugins.nowinandroid.android.application.jacoco) + alias(libs.plugins.nowinandroid.android.hilt) id("jacoco") - id("nowinandroid.android.application.firebase") + alias(libs.plugins.nowinandroid.android.application.firebase) id("com.google.android.gms.oss-licenses-plugin") + alias(libs.plugins.baselineprofile) } android { @@ -43,7 +44,7 @@ android { debug { applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix } - val release by getting { + val release = getByName("release") { isMinifyEnabled = true applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix 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. // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. signingConfig = signingConfigs.getByName("debug") + // Ensure Baseline Profile is fresh for release builds. + baselineProfile.automaticGenerationDuringBuild = true } create("benchmark") { // Enable all the optimizations from release build through initWith(release). @@ -80,31 +83,31 @@ android { } dependencies { - implementation(project(":feature:interests")) - implementation(project(":feature:foryou")) - implementation(project(":feature:bookmarks")) - implementation(project(":feature:topic")) - implementation(project(":feature:search")) - implementation(project(":feature:settings")) - - implementation(project(":core:common")) - implementation(project(":core:ui")) - implementation(project(":core:designsystem")) - implementation(project(":core:data")) - implementation(project(":core:model")) - implementation(project(":core:analytics")) - - implementation(project(":sync:work")) - - androidTestImplementation(project(":core:testing")) - androidTestImplementation(project(":core:datastore-test")) - androidTestImplementation(project(":core:data-test")) - androidTestImplementation(project(":core:network")) + implementation(projects.feature.interests) + implementation(projects.feature.foryou) + implementation(projects.feature.bookmarks) + implementation(projects.feature.topic) + implementation(projects.feature.search) + implementation(projects.feature.settings) + + implementation(projects.core.common) + implementation(projects.core.ui) + implementation(projects.core.designsystem) + implementation(projects.core.data) + implementation(projects.core.model) + implementation(projects.core.analytics) + + implementation(projects.sync.work) + + androidTestImplementation(projects.core.testing) + androidTestImplementation(projects.core.datastoreTest) + androidTestImplementation(projects.core.dataTest) + androidTestImplementation(projects.core.network) androidTestImplementation(libs.androidx.navigation.testing) androidTestImplementation(libs.accompanist.testharness) androidTestImplementation(kotlin("test")) debugImplementation(libs.androidx.compose.ui.testManifest) - debugImplementation(project(":ui-test-hilt-manifest")) + debugImplementation(projects.uiTestHiltManifest) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) @@ -121,15 +124,27 @@ dependencies { implementation(libs.kotlinx.coroutines.guava) implementation(libs.coil.kt) + baselineProfile(project(":benchmarks")) + // Core functions - testImplementation(project(":core:testing")) - testImplementation(project(":core:datastore-test")) - testImplementation(project(":core:data-test")) - testImplementation(project(":core:network")) + testImplementation(projects.core.testing) + testImplementation(projects.core.datastoreTest) + testImplementation(projects.core.dataTest) + testImplementation(projects.core.network) testImplementation(libs.androidx.navigation.testing) testImplementation(libs.accompanist.testharness) testImplementation(libs.work.testing) 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") } diff --git a/app/dependencies/prodReleaseRuntimeClasspath.txt b/app/dependencies/prodReleaseRuntimeClasspath.txt new file mode 100644 index 000000000..8123217b9 --- /dev/null +++ b/app/dependencies/prodReleaseRuntimeClasspath.txt @@ -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 diff --git a/app/prodRelease-badging.txt b/app/prodRelease-badging.txt new file mode 100644 index 000000000..6c3a859c7 --- /dev/null +++ b/app/prodRelease-badging.txt @@ -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' diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index e1eab4796..7a7a82180 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -42,7 +42,7 @@ import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.junit.Before import org.junit.Rule import org.junit.Test @@ -51,7 +51,7 @@ import javax.inject.Inject import kotlin.properties.ReadOnlyProperty import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR import com.google.samples.apps.nowinandroid.feature.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 /** @@ -93,15 +93,15 @@ class NavigationTest { ReadOnlyProperty { _, _ -> activity.getString(resId) } // The strings used for matching in these tests - private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up) - private val forYou by composeTestRule.stringResource(FeatureForyouR.string.for_you) - private val interests by composeTestRule.stringResource(FeatureInterestsR.string.interests) + private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up) + private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_title) + private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_interests) private val sampleTopic = "Headlines" private val appName by composeTestRule.stringResource(R.string.app_name) - private val saved by composeTestRule.stringResource(BookmarksR.string.saved) - private val settings by composeTestRule.stringResource(SettingsR.string.top_app_bar_action_icon_description) - private val brand by composeTestRule.stringResource(SettingsR.string.brand_android) - private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text) + private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_title) + private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_top_app_bar_action_icon_description) + private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_brand_android) + private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_dismiss_dialog_button_text) @Before fun setup() = hiltRule.inject() @@ -166,7 +166,10 @@ class NavigationTest { composeTestRule.apply { // GIVEN the user is on any of the top level destinations, THEN the Up arrow is not shown. onNodeWithContentDescription(navigateUp).assertDoesNotExist() - // TODO: Add top level destinations here, see b/226357686. + + onNodeWithText(saved).performClick() + onNodeWithContentDescription(navigateUp).assertDoesNotExist() + onNodeWithText(interests).performClick() onNodeWithContentDescription(navigateUp).assertDoesNotExist() } @@ -265,12 +268,14 @@ class NavigationTest { } @Test - fun navigationBar_multipleBackStackInterests() = runTest { + fun navigationBar_multipleBackStackInterests() { composeTestRule.apply { onNodeWithText(interests).performClick() // 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)) onNodeWithText(topic).performClick() diff --git a/app/src/benchmark/res/values-night/colors.xml b/app/src/benchmark/res/values-night/colors.xml new file mode 100644 index 000000000..cbf22c766 --- /dev/null +++ b/app/src/benchmark/res/values-night/colors.xml @@ -0,0 +1,22 @@ + + + + + #FFFFFF + #FF006780 + diff --git a/app/src/benchmark/res/values/colors.xml b/app/src/benchmark/res/values/colors.xml new file mode 100644 index 000000000..a98c6d8f6 --- /dev/null +++ b/app/src/benchmark/res/values/colors.xml @@ -0,0 +1,22 @@ + + + + + #000000 + #FF006780 + diff --git a/sync/work/src/demo/AndroidManifest.xml b/app/src/debug/res/values-night/colors.xml similarity index 61% rename from sync/work/src/demo/AndroidManifest.xml rename to app/src/debug/res/values-night/colors.xml index 8dc32c86f..daa017e4a 100644 --- a/sync/work/src/demo/AndroidManifest.xml +++ b/app/src/debug/res/values-night/colors.xml @@ -1,6 +1,6 @@ - - - - - - - + + + #FFFFFF + #FFA23F16 + diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml new file mode 100644 index 000000000..487a7820b --- /dev/null +++ b/app/src/debug/res/values/colors.xml @@ -0,0 +1,22 @@ + + + + + #000000 + #FFA23F16 + diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index 3d58ed5a6..6167b0b59 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost 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.interests.navigation.interestsGraph import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen @@ -41,7 +41,7 @@ fun NiaNavHost( appState: NiaAppState, onShowSnackbar: suspend (String, String?) -> Boolean, modifier: Modifier = Modifier, - startDestination: String = forYouNavigationRoute, + startDestination: String = FOR_YOU_ROUTE, ) { val navController = appState.navController NavHost( diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt index 8dbd0fcb6..aca7d54ab 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt @@ -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.feature.bookmarks.R as bookmarksR 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 @@ -37,19 +37,19 @@ enum class TopLevelDestination( FOR_YOU( selectedIcon = NiaIcons.Upcoming, unselectedIcon = NiaIcons.UpcomingBorder, - iconTextId = forYouR.string.for_you, + iconTextId = forYouR.string.feature_foryou_title, titleTextId = R.string.app_name, ), BOOKMARKS( selectedIcon = NiaIcons.Bookmarks, unselectedIcon = NiaIcons.BookmarksBorder, - iconTextId = bookmarksR.string.saved, - titleTextId = bookmarksR.string.saved, + iconTextId = bookmarksR.string.feature_bookmarks_title, + titleTextId = bookmarksR.string.feature_bookmarks_title, ), INTERESTS( selectedIcon = NiaIcons.Grid3x3, unselectedIcon = NiaIcons.Grid3x3, - iconTextId = interestsR.string.interests, - titleTextId = interestsR.string.interests, + iconTextId = searchR.string.feature_search_interests, + titleTextId = searchR.string.feature_search_interests, ), } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index aa85afebd..1660581a4 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -183,11 +183,11 @@ fun NiaApp( titleRes = destination.titleTextId, navigationIcon = NiaIcons.Search, 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, 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( containerColor = Color.Transparent, diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 09e70069e..b99eab245 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -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.util.NetworkMonitor 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.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.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.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination @@ -91,9 +91,9 @@ class NiaAppState( val currentTopLevelDestination: TopLevelDestination? @Composable get() = when (currentDestination?.route) { - forYouNavigationRoute -> FOR_YOU - bookmarksRoute -> BOOKMARKS - interestsRoute -> INTERESTS + FOR_YOU_ROUTE -> FOR_YOU + BOOKMARKS_ROUTE -> BOOKMARKS + INTERESTS_ROUTE -> INTERESTS else -> null } diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt index d16d9bd93..dcbc1e5c0 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -65,7 +65,7 @@ import javax.inject.Inject @GraphicsMode(GraphicsMode.Mode.NATIVE) // Configure Robolectric to use a very large screen size that can fit all of the test sizes. // This allows enough room to render the content under test without clipping or scaling. -@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi", sdk = [33]) +@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi") @LooperMode(LooperMode.Mode.PAUSED) @HiltAndroidTest class NiaAppScreenSizesScreenshotTests { @@ -162,7 +162,7 @@ class NiaAppScreenSizesScreenshotTests { @Test fun compactWidth_compactHeight_showsNavigationBar() { testNiaAppScreenshotWithSize( - 610.dp, + 400.dp, 400.dp, "compactWidth_compactHeight_showsNavigationBar", ) diff --git a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png index 56b49457c..edb9cfa2a 100644 Binary files a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png and b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png index 25283c111..523b03ec5 100644 Binary files a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png and b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png differ diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 48a6687e4..67fccb979 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -17,7 +17,8 @@ import com.google.samples.apps.nowinandroid.NiaBuildType import com.google.samples.apps.nowinandroid.configureFlavors plugins { - id("nowinandroid.android.test") + alias(libs.plugins.baselineprofile) + alias(libs.plugins.nowinandroid.android.test) } android { @@ -62,10 +63,27 @@ android { ) } + testOptions.managedDevices.devices { + create("pixel6Api33") { + device = "Pixel 6" + apiLevel = 33 + systemImageSource = "aosp" + } + } + targetProjectPath = ":app" 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 { implementation(libs.androidx.benchmark.macro) implementation(libs.androidx.test.core) @@ -75,9 +93,3 @@ dependencies { implementation(libs.androidx.test.runner) implementation(libs.androidx.test.uiautomator) } - -androidComponents { - beforeVariants { - it.enable = it.buildType == "benchmark" - } -} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt index 48472e523..8df52104a 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/GeneralActions.kt @@ -20,6 +20,10 @@ import android.Manifest.permission import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION_CODES.TIRAMISU 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, @@ -42,3 +46,27 @@ fun MacrobenchmarkScope.allowNotifications() { 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) +} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BookmarksBaselineProfile.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BookmarksBaselineProfile.kt new file mode 100644 index 000000000..eca3f059b --- /dev/null +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BookmarksBaselineProfile.kt @@ -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() + } +} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/ForYouBaselineProfile.kt similarity index 59% rename from benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt rename to benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/ForYouBaselineProfile.kt index fcbbc1049..e8722e116 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/ForYouBaselineProfile.kt @@ -18,43 +18,27 @@ 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.allowNotifications -import com.google.samples.apps.nowinandroid.bookmarks.goToBookmarksScreen import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp import com.google.samples.apps.nowinandroid.foryou.forYouSelectTopics import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent -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 /** - * 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() @Test fun generate() = baselineProfileRule.collect(PACKAGE_NAME) { - // This block defines the app's critical user journey. Here we are interested in - // optimizing for app startup. But you can also navigate and scroll - // through your most important UI. - allowNotifications() - pressHome() - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() // Scroll the feed critical user journey forYouWaitForContent() forYouSelectTopics(true) forYouScrollFeedDownUp() - - // Navigate to saved screen - goToBookmarksScreen() - - // Navigate to interests screen - goToInterestsScreen() - interestsScrollTopicsDownUp() } } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/InterestsBaselineProfile.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/InterestsBaselineProfile.kt new file mode 100644 index 000000000..dd2166dc2 --- /dev/null +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/InterestsBaselineProfile.kt @@ -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() + } +} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/StartupBaselineProfile.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/StartupBaselineProfile.kt new file mode 100644 index 000000000..c5a88e1bd --- /dev/null +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/baselineprofile/StartupBaselineProfile.kt @@ -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() + } +} diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt index f66fa27a2..eb01c7d1b 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt @@ -18,13 +18,13 @@ package com.google.samples.apps.nowinandroid.bookmarks import androidx.benchmark.macro.MacrobenchmarkScope import androidx.test.uiautomator.By -import androidx.test.uiautomator.Until +import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar fun MacrobenchmarkScope.goToBookmarksScreen() { - device.findObject(By.text("Saved")).click() + val savedSelector = By.text("Saved") + val savedButton = device.findObject(savedSelector) + savedButton.click() device.waitForIdle() // Wait until saved title are shown on screen - device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) - val topAppBar = device.findObject(By.res("niaTopAppBar")) - topAppBar.wait(Until.hasObject(By.text("Saved")), 2_000) + waitForObjectOnTopAppBar(savedSelector) } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt index 672c3f52f..20b941a24 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ForYouActions.kt @@ -22,6 +22,8 @@ import androidx.test.uiautomator.Until import androidx.test.uiautomator.untilHasChildren import com.google.samples.apps.nowinandroid.flingElementDownUp import com.google.samples.apps.nowinandroid.waitAndFindObject +import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar +import org.junit.Assert.fail fun MacrobenchmarkScope.forYouWaitForContent() { // Wait until content is loaded by checking if topics are loaded @@ -49,6 +51,9 @@ fun MacrobenchmarkScope.forYouSelectTopics(recheckTopicsIfChecked: Boolean = fal var visited = 0 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. val topic = topics.children[index % topics.childCount] // 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() // Wait until the top app bar is visible on screen - device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) - val topAppBar = device.findObject(By.res("niaTopAppBar")) - topAppBar.wait(Until.hasObject(By.text("Now in Android")), 2_000) + waitForObjectOnTopAppBar(By.text("Now in Android")) } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt index 18a7a717b..6d0091cd4 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt @@ -22,7 +22,7 @@ import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner 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.Test import org.junit.runner.RunWith @@ -47,8 +47,7 @@ class ScrollForYouFeedBenchmark { setupBlock = { // Start the app pressHome() - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() }, ) { forYouWaitForContent() diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt index d9c563ebc..05b276faa 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt @@ -20,21 +20,21 @@ import androidx.benchmark.macro.MacrobenchmarkScope import androidx.test.uiautomator.By import androidx.test.uiautomator.Until import com.google.samples.apps.nowinandroid.flingElementDownUp +import com.google.samples.apps.nowinandroid.waitForObjectOnTopAppBar fun MacrobenchmarkScope.goToInterestsScreen() { device.findObject(By.text("Interests")).click() device.waitForIdle() // Wait until interests are shown on screen - device.wait(Until.hasObject(By.res("niaTopAppBar")), 2_000) - val topAppBar = device.findObject(By.res("niaTopAppBar")) - topAppBar.wait(Until.hasObject(By.text("Interests")), 2_000) + waitForObjectOnTopAppBar(By.text("Interests")) // Wait until content is loaded by checking if interests are loaded device.wait(Until.gone(By.res("loadingWheel")), 5_000) } 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) } diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt index b43d3a84b..b53e2e05c 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/ScrollTopicListBenchmark.kt @@ -23,7 +23,7 @@ import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.uiautomator.By 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.Test import org.junit.runner.RunWith @@ -47,8 +47,7 @@ class ScrollTopicListBenchmark { setupBlock = { // Start the app pressHome() - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() // Navigate to interests screen device.findObject(By.text("Interests")).click() device.waitForIdle() diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt index 0030386b7..faf0803f3 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt @@ -23,7 +23,7 @@ import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.uiautomator.By 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.Test import org.junit.runner.RunWith @@ -47,8 +47,7 @@ class TopicsScreenRecompositionBenchmark { setupBlock = { // Start the app pressHome() - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() // Navigate to interests screen device.findObject(By.text("Interests")).click() device.waitForIdle() diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt index 669e05b82..96bea89b8 100644 --- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt +++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/startup/StartupBenchmark.kt @@ -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.allowNotifications import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent +import com.google.samples.apps.nowinandroid.startActivityAndAllowNotifications import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -41,32 +42,33 @@ class StartupBenchmark { val benchmarkRule = MacrobenchmarkRule() @Test - fun startupNoCompilation() = startup(CompilationMode.None()) + fun startupWithoutPreCompilation() = startup(CompilationMode.None()) @Test - fun startupBaselineProfileDisabled() = startup( + fun startupWithPartialCompilationAndDisabledBaselineProfile() = startup( CompilationMode.Partial(baselineProfileMode = Disable, warmupIterations = 1), ) @Test - fun startupBaselineProfile() = startup(CompilationMode.Partial(baselineProfileMode = Require)) + fun startupPrecompiledWithBaselineProfile() = + startup(CompilationMode.Partial(baselineProfileMode = Require)) @Test - fun startupFullCompilation() = startup(CompilationMode.Full()) + fun startupFullyPrecompiled() = startup(CompilationMode.Full()) private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated( packageName = PACKAGE_NAME, metrics = listOf(StartupTimingMetric()), compilationMode = compilationMode, - iterations = 10, + // More iterations result in higher statistical significance. + iterations = 20, startupMode = COLD, setupBlock = { pressHome() allowNotifications() }, ) { - startActivityAndWait() - allowNotifications() + startActivityAndAllowNotifications() // Waits until the content is ready to capture Time To Full Display forYouWaitForContent() } diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 9230dd6b7..aa0e615ad 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -36,10 +36,20 @@ tasks.withType().configureEach { dependencies { compileOnly(libs.android.gradlePlugin) + compileOnly(libs.android.tools.common) compileOnly(libs.firebase.crashlytics.gradlePlugin) compileOnly(libs.firebase.performance.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) + compileOnly(libs.room.gradlePlugin) + implementation(libs.truth) +} + +tasks { + validatePlugins { + enableStricterValidation = true + failOnWarning = true + } } gradlePlugin { diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 50baf3dc6..cd8bcfeb0 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -15,13 +15,16 @@ */ 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.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.configurePrintApksTask import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.getByType class AndroidApplicationConventionPlugin : Plugin { override fun apply(target: Project) { @@ -30,6 +33,7 @@ class AndroidApplicationConventionPlugin : Plugin { apply("com.android.application") apply("org.jetbrains.kotlin.android") apply("nowinandroid.android.lint") + apply("com.dropbox.dependency-guard") } extensions.configure { @@ -39,8 +43,9 @@ class AndroidApplicationConventionPlugin : Plugin { } extensions.configure { configurePrintApksTask(this) + configureBadgingTasks(extensions.getByType(), this) } } } -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt index a0e81a27c..b24594570 100644 --- a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -23,17 +23,15 @@ class AndroidHiltConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { with(pluginManager) { + apply("com.google.devtools.ksp") 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 { "implementation"(libs.findLibrary("hilt.android").get()) - "kapt"(libs.findLibrary("hilt.compiler").get()) - "kaptAndroidTest"(libs.findLibrary("hilt.compiler").get()) - "kaptTest"(libs.findLibrary("hilt.compiler").get()) + "ksp"(libs.findLibrary("hilt.compiler").get()) + "kspAndroidTest"(libs.findLibrary("hilt.compiler").get()) + "kspTest"(libs.findLibrary("hilt.compiler").get()) } } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index ef84cfbb4..995b922a2 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -41,6 +41,9 @@ class AndroidLibraryConventionPlugin : Plugin { defaultConfig.targetSdk = 34 configureFlavors(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 { configurePrintApksTask(this) diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt index b67fb1b26..29d31f9e6 100644 --- a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt @@ -14,29 +14,25 @@ * limitations under the License. */ -import com.google.devtools.ksp.gradle.KspExtension +import androidx.room.gradle.RoomExtension import com.google.samples.apps.nowinandroid.libs import org.gradle.api.Plugin 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.dependencies -import org.gradle.process.CommandLineArgumentProvider -import java.io.File class AndroidRoomConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { + pluginManager.apply("androidx.room") pluginManager.apply("com.google.devtools.ksp") - extensions.configure { + extensions.configure { // The schemas directory contains a schema file for each version of the Room database. // This is required to enable Room auto migrations. // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration. - arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) + schemaDirectory("$projectDir/schemas") } dependencies { @@ -46,16 +42,4 @@ class AndroidRoomConventionPlugin : Plugin { } } } - - /** - * 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}") - } } \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 6d1b5342d..614d4f2d0 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -67,10 +67,10 @@ private fun Project.buildComposeMetricsParameters(): List { val metricParameters = mutableListOf() val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics") val relativePath = projectDir.relativeTo(rootDir) - + val buildDir = layout.buildDirectory.get().asFile val enableMetrics = (enableMetricsProvider.orNull == "true") if (enableMetrics) { - val metricsFolder = rootProject.buildDir.resolve("compose-metrics").resolve(relativePath) + val metricsFolder = buildDir.resolve("compose-metrics").resolve(relativePath) metricParameters.add("-P") metricParameters.add( "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath @@ -80,7 +80,7 @@ private fun Project.buildComposeMetricsParameters(): List { val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports") val enableReports = (enableReportsProvider.orNull == "true") if (enableReports) { - val reportsFolder = rootProject.buildDir.resolve("compose-reports").resolve(relativePath) + val reportsFolder = buildDir.resolve("compose-reports").resolve(relativePath) metricParameters.add("-P") metricParameters.add( "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt new file mode 100644 index 000000000..c59d3ffb8 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt @@ -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 + + 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(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(updateBadgingTaskName) { + from(generateBadging.get().badging) + into(project.layout.projectDirectory) + } + + val checkBadgingTaskName = "check${capitalizedVariantName}Badging" + tasks.register(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"), + ) + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt index 70eef1a2d..596c4f579 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt @@ -50,7 +50,7 @@ internal fun Project.configureJacoco( androidComponentsExtension.onVariants { variant -> val testTaskName = "test${variant.name.capitalize()}UnitTest" - + val buildDir = layout.buildDirectory.get().asFile val reportTask = tasks.register("jacoco${testTaskName.capitalize()}Report", JacocoReport::class) { dependsOn(testTaskName) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 04ee4e56e..903c84d8f 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -83,10 +83,8 @@ private fun Project.configureKotlin() { val warningsAsErrors: String? by project allWarningsAsErrors = warningsAsErrors.toBoolean() freeCompilerArgs = freeCompilerArgs + listOf( - "-opt-in=kotlin.RequiresOptIn", // Enable experimental coroutines APIs, including Flow "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-opt-in=kotlinx.coroutines.FlowPreview", ) } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt index 6c08216cc..94bf6e127 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/PrintTestApks.kt @@ -30,7 +30,10 @@ import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFiles 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.work.DisableCachingByDefault import java.io.File 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() { + + @get:PathSensitive(PathSensitivity.RELATIVE) @get:InputDirectory abstract val apkFolder: DirectoryProperty + @get:PathSensitive(PathSensitivity.RELATIVE) @get:InputFiles abstract val sources: ListProperty diff --git a/build.gradle.kts b/build.gradle.kts index 1efa3f8be..dbde27b38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,8 +32,11 @@ buildscript { // Lists all plugins used throughout the project without applying them. plugins { 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.serialization) apply false + alias(libs.plugins.dependencyGuard) apply false alias(libs.plugins.firebase.crashlytics) apply false alias(libs.plugins.firebase.perf) apply false alias(libs.plugins.gms) apply false @@ -41,4 +44,5 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.roborazzi) apply false alias(libs.plugins.secrets) apply false + alias(libs.plugins.room) apply false } diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index 8c573b854..0f712085c 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.hilt) } android { diff --git a/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt b/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt index 9f875ae6d..0d2e0e274 100644 --- a/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt +++ b/core/analytics/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -35,6 +35,8 @@ abstract class AnalyticsModule { companion object { @Provides @Singleton - fun provideFirebaseAnalytics(): FirebaseAnalytics { return Firebase.analytics } + fun provideFirebaseAnalytics(): FirebaseAnalytics { + return Firebase.analytics + } } } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 491dffd80..d539d1892 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.jacoco") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.jacoco) + alias(libs.plugins.nowinandroid.android.hilt) } android { @@ -25,5 +25,5 @@ android { dependencies { implementation(libs.kotlinx.coroutines.android) - testImplementation(project(":core:testing")) + testImplementation(projects.core.testing) } \ No newline at end of file diff --git a/core/common/src/main/res/drawable-anydpi-v24/ic_nia_notification.xml b/core/common/src/main/res/drawable-anydpi-v24/core_common_ic_nia_notification.xml similarity index 100% rename from core/common/src/main/res/drawable-anydpi-v24/ic_nia_notification.xml rename to core/common/src/main/res/drawable-anydpi-v24/core_common_ic_nia_notification.xml diff --git a/core/common/src/main/res/drawable-hdpi/ic_nia_notification.png b/core/common/src/main/res/drawable-hdpi/core_common_ic_nia_notification.png similarity index 100% rename from core/common/src/main/res/drawable-hdpi/ic_nia_notification.png rename to core/common/src/main/res/drawable-hdpi/core_common_ic_nia_notification.png diff --git a/core/common/src/main/res/drawable-mdpi/ic_nia_notification.png b/core/common/src/main/res/drawable-mdpi/core_common_ic_nia_notification.png similarity index 100% rename from core/common/src/main/res/drawable-mdpi/ic_nia_notification.png rename to core/common/src/main/res/drawable-mdpi/core_common_ic_nia_notification.png diff --git a/core/common/src/main/res/drawable-xhdpi/ic_nia_notification.png b/core/common/src/main/res/drawable-xhdpi/core_common_ic_nia_notification.png similarity index 100% rename from core/common/src/main/res/drawable-xhdpi/ic_nia_notification.png rename to core/common/src/main/res/drawable-xhdpi/core_common_ic_nia_notification.png diff --git a/core/common/src/main/res/drawable-xxhdpi/ic_nia_notification.png b/core/common/src/main/res/drawable-xxhdpi/core_common_ic_nia_notification.png similarity index 100% rename from core/common/src/main/res/drawable-xxhdpi/ic_nia_notification.png rename to core/common/src/main/res/drawable-xxhdpi/core_common_ic_nia_notification.png diff --git a/core/data-test/build.gradle.kts b/core/data-test/build.gradle.kts index dfc224e19..7ca3ecd0d 100644 --- a/core/data-test/build.gradle.kts +++ b/core/data-test/build.gradle.kts @@ -14,8 +14,8 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.hilt) } android { @@ -23,7 +23,7 @@ android { } dependencies { - api(project(":core:data")) - implementation(project(":core:testing")) - implementation(project(":core:common")) + api(projects.core.data) + implementation(projects.core.testing) + implementation(projects.core.common) } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 51dfb5393..e730a9eb3 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.jacoco") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.jacoco) + alias(libs.plugins.nowinandroid.android.hilt) id("kotlinx-serialization") } @@ -31,18 +31,18 @@ android { } dependencies { - implementation(project(":core:analytics")) - implementation(project(":core:common")) - implementation(project(":core:database")) - implementation(project(":core:datastore")) - implementation(project(":core:model")) - implementation(project(":core:network")) - implementation(project(":core:notifications")) + implementation(projects.core.analytics) + implementation(projects.core.common) + implementation(projects.core.database) + implementation(projects.core.datastore) + implementation(projects.core.model) + implementation(projects.core.network) + implementation(projects.core.notifications) implementation(libs.androidx.core.ktx) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) - testImplementation(project(":core:datastore-test")) - testImplementation(project(":core:testing")) + testImplementation(projects.core.datastoreTest) + testImplementation(projects.core.testing) } diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index a9c711ae3..a1075286d 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -15,10 +15,10 @@ */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.jacoco") - id("nowinandroid.android.hilt") - id("nowinandroid.android.room") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.jacoco) + alias(libs.plugins.nowinandroid.android.hilt) + alias(libs.plugins.nowinandroid.android.room) } android { @@ -30,10 +30,10 @@ android { } dependencies { - implementation(project(":core:model")) + implementation(projects.core.model) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) - androidTestImplementation(project(":core:testing")) + androidTestImplementation(projects.core.testing) } diff --git a/core/datastore-proto/build.gradle.kts b/core/datastore-proto/build.gradle.kts new file mode 100644 index 000000000..157b5336e --- /dev/null +++ b/core/datastore-proto/build.gradle.kts @@ -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) +} diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto b/core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto similarity index 100% rename from core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto rename to core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/dark_theme_config.proto diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto b/core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto similarity index 100% rename from core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto rename to core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/theme_brand.proto diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto similarity index 100% rename from core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto rename to core/datastore-proto/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto diff --git a/core/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts index 193c49da7..04b15f044 100644 --- a/core/datastore-test/build.gradle.kts +++ b/core/datastore-test/build.gradle.kts @@ -14,8 +14,8 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.hilt) } android { @@ -23,10 +23,10 @@ android { } dependencies { - api(project(":core:datastore")) + api(projects.core.datastore) api(libs.androidx.dataStore.core) implementation(libs.protobuf.kotlin.lite) - implementation(project(":core:common")) - implementation(project(":core:testing")) + implementation(projects.core.common) + implementation(projects.core.testing) } diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index d6ca7ebcd..afe036640 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -15,10 +15,9 @@ */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.jacoco") - id("nowinandroid.android.hilt") - alias(libs.plugins.protobuf) + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.jacoco) + alias(libs.plugins.nowinandroid.android.hilt) } 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 { - implementation(project(":core:common")) - implementation(project(":core:model")) + api(projects.core.datastoreProto) + implementation(projects.core.common) + implementation(projects.core.model) implementation(libs.androidx.dataStore.core) implementation(libs.kotlinx.coroutines.android) implementation(libs.protobuf.kotlin.lite) - testImplementation(project(":core:datastore-test")) - testImplementation(project(":core:testing")) + testImplementation(projects.core.datastoreTest) + testImplementation(projects.core.testing) } diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index cf9873e2c..7bd1d12d8 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) } android { @@ -27,7 +27,7 @@ android { } dependencies { - lintPublish(project(":lint")) + lintPublish(projects.lint) api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation.layout) @@ -42,5 +42,5 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.coil.kt.compose) - androidTestImplementation(project(":core:testing")) + androidTestImplementation(projects.core.testing) } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt index 966014fd9..d490ff13e 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Button.kt @@ -134,7 +134,7 @@ fun NiaOutlinedButton( MaterialTheme.colorScheme.outline } else { MaterialTheme.colorScheme.onSurface.copy( - alpha = NiaButtonDefaults.DisabledOutlinedButtonBorderAlpha, + alpha = NiaButtonDefaults.DISABLED_OUTLINED_BUTTON_BORDER_ALPHA, ) }, ), @@ -278,7 +278,7 @@ fun NiaButtonPreview() { @ThemePreviews @Composable fun NiaOutlinedButtonPreview() { - NiaTheme() { + NiaTheme { NiaBackground(modifier = Modifier.size(150.dp, 50.dp)) { NiaOutlinedButton(onClick = {}, text = { Text("Test button") }) } @@ -315,7 +315,7 @@ fun NiaButtonLeadingIconPreview() { object NiaButtonDefaults { // TODO: File bug // 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 // OutlinedButton default border width isn't exposed via ButtonDefaults diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt index d1b7d124d..106f0b839 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Chip.kt @@ -76,10 +76,10 @@ fun NiaFilterChip( borderColor = MaterialTheme.colorScheme.onBackground, selectedBorderColor = MaterialTheme.colorScheme.onBackground, disabledBorderColor = MaterialTheme.colorScheme.onBackground.copy( - alpha = NiaChipDefaults.DisabledChipContentAlpha, + alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA, ), disabledSelectedBorderColor = MaterialTheme.colorScheme.onBackground.copy( - alpha = NiaChipDefaults.DisabledChipContentAlpha, + alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA, ), selectedBorderWidth = NiaChipDefaults.ChipBorderWidth, ), @@ -88,16 +88,16 @@ fun NiaFilterChip( iconColor = MaterialTheme.colorScheme.onBackground, disabledContainerColor = if (selected) { MaterialTheme.colorScheme.onBackground.copy( - alpha = NiaChipDefaults.DisabledChipContainerAlpha, + alpha = NiaChipDefaults.DISABLED_CHIP_CONTAINER_ALPHA, ) } else { Color.Transparent }, disabledLabelColor = MaterialTheme.colorScheme.onBackground.copy( - alpha = NiaChipDefaults.DisabledChipContentAlpha, + alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA, ), disabledLeadingIconColor = MaterialTheme.colorScheme.onBackground.copy( - alpha = NiaChipDefaults.DisabledChipContentAlpha, + alpha = NiaChipDefaults.DISABLED_CHIP_CONTENT_ALPHA, ), selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedLabelColor = MaterialTheme.colorScheme.onBackground, @@ -124,7 +124,7 @@ fun ChipPreview() { object NiaChipDefaults { // TODO: File bug // FilterChip default values aren't exposed via FilterChipDefaults - const val DisabledChipContainerAlpha = 0.12f - const val DisabledChipContentAlpha = 0.38f + const val DISABLED_CHIP_CONTAINER_ALPHA = 0.12f + const val DISABLED_CHIP_CONTENT_ALPHA = 0.38f val ChipBorderWidth = 1.dp } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt index cc352107b..bd22fa168 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/DynamicAsyncImage.kt @@ -49,7 +49,7 @@ fun DynamicAsyncImage( imageUrl: String, contentDescription: String?, 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 var isLoading by remember { mutableStateOf(true) } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt index 503342d30..43ec11f0b 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/IconButton.kt @@ -60,7 +60,7 @@ fun NiaIconToggleButton( checkedContentColor = MaterialTheme.colorScheme.onPrimaryContainer, disabledContainerColor = if (checked) { MaterialTheme.colorScheme.onBackground.copy( - alpha = NiaIconButtonDefaults.DisabledIconButtonContainerAlpha, + alpha = NiaIconButtonDefaults.DISABLED_ICON_BUTTON_CONTAINER_ALPHA, ) } else { Color.Transparent @@ -123,5 +123,5 @@ fun IconButtonPreviewUnchecked() { object NiaIconButtonDefaults { // TODO: File bug // IconToggleButton disabled container alpha not exposed by IconButtonDefaults - const val DisabledIconButtonContainerAlpha = 0.12f + const val DISABLED_ICON_BUTTON_CONTAINER_ALPHA = 0.12f } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt index 8ca1588a8..290845936 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/Tag.kt @@ -40,7 +40,7 @@ fun NiaTopicTag( MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surfaceVariant.copy( - alpha = NiaTagDefaults.UnfollowedTopicTagContainerAlpha, + alpha = NiaTagDefaults.UNFOLLOWED_TOPIC_TAG_CONTAINER_ALPHA, ) } TextButton( @@ -50,7 +50,7 @@ fun NiaTopicTag( containerColor = containerColor, contentColor = contentColorFor(backgroundColor = containerColor), 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. */ object NiaTagDefaults { - const val UnfollowedTopicTagContainerAlpha = 0.5f + const val UNFOLLOWED_TOPIC_TAG_CONTAINER_ALPHA = 0.5f // TODO: File bug // Button disabled container alpha value not exposed by ButtonDefaults - const val DisabledTopicTagContainerAlpha = 0.12f + const val DISABLED_TOPIC_TAG_CONTAINER_ALPHA = 0.12f } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt index fa913cb27..c8102073a 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/AppScrollbars.kt @@ -16,10 +16,10 @@ package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar +import android.annotation.SuppressLint import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Spring import androidx.compose.animation.core.SpringSpec -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation.Horizontal 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size 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 com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.ThumbState.Active 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() } } - .background( - color = scrollbarThumbColor( - interactionSource = interactionSource, - ), - shape = RoundedCornerShape(16.dp), - ), + .scrollThumb(this, interactionSource), ) } @@ -155,31 +160,72 @@ private fun ScrollableState.DecorativeScrollbarThumb( Horizontal -> height(2.dp).fillMaxWidth() } } - .background( - color = scrollbarThumbColor( - interactionSource = interactionSource, - ), - shape = RoundedCornerShape(16.dp), - ), + .scrollThumb(this, interactionSource), ) } +// 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() { + 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. * @param interactionSource source of interactions in the scrolling container */ @Composable -private fun ScrollableState.scrollbarThumbColor( +private fun scrollbarThumbColor( + scrollableState: ScrollableState, interactionSource: InteractionSource, -): Color { +): State { var state by remember { mutableStateOf(Dormant) } val pressed by interactionSource.collectIsPressedAsState() val hovered by interactionSource.collectIsHoveredAsState() val dragged by interactionSource.collectIsDraggedAsState() - val active = (canScrollForward || canScrollForward) && - (pressed || hovered || dragged || isScrollInProgress) + val active = (scrollableState.canScrollForward || scrollableState.canScrollBackward) && + (pressed || hovered || dragged || scrollableState.isScrollInProgress) - val color by animateColorAsState( + val color = animateColorAsState( targetValue = when (state) { Active -> MaterialTheme.colorScheme.onSurface.copy(0.5f) Inactive -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) @@ -205,5 +251,7 @@ private fun ScrollableState.scrollbarThumbColor( } private enum class ThumbState { - Active, Inactive, Dormant + Active, + Inactive, + Dormant, } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt index 8c4063b15..57e567b5d 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/LazyScrollbarUtilities.kt @@ -17,79 +17,7 @@ package com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar 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.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.scrollbarState( - itemsAvailable: Int, - crossinline visibleItems: LazyState.() -> List, - crossinline firstVisibleItemIndex: LazyState.(List) -> 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 diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt index 74d9e0467..8c85e5be5 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/Scrollbar.kt @@ -16,8 +16,9 @@ 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.Horizontal +import androidx.compose.foundation.gestures.Orientation.Vertical import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures 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.fillMaxHeight 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.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange 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.positionInRoot -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max import androidx.compose.ui.util.packFloats import androidx.compose.ui.util.unpackFloat1 import androidx.compose.ui.util.unpackFloat2 @@ -60,6 +59,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import kotlin.math.max 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 @@ -73,21 +73,59 @@ private const val SCROLLBAR_PRESS_DELAY_MS = 10L */ 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 */ @Immutable @JvmInline -value class ScrollbarState internal constructor( +value class ScrollbarStateValue internal constructor( internal val packedValue: Long, -) { - companion object { - val FULL = ScrollbarState( - thumbSizePercent = 1f, - thumbMovedPercent = 0f, - ) - } -} +) /** * 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. * Refers to either the thumb width (for horizontal scrollbars) * or height (for vertical scrollbars). * @param thumbMovedPercent the distance the thumb has traveled as a percentage of total * track size. */ -fun ScrollbarState( +fun scrollbarStateValue( thumbSizePercent: Float, thumbMovedPercent: Float, -) = ScrollbarState( +) = ScrollbarStateValue( packFloats( val1 = thumbSizePercent, 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] */ @@ -196,8 +203,6 @@ fun Scrollbar( thumb: @Composable () -> Unit, onThumbMoved: ((Float) -> Unit)? = null, ) { - val localDensity = LocalDensity.current - // Using Offset.Unspecified and Float.NaN instead of null // to prevent unnecessary boxing of primitives 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 // catches up - var interactionThumbTravelPercent by remember { mutableStateOf(Float.NaN) } + var interactionThumbTravelPercent by remember { mutableFloatStateOf(Float.NaN) } 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 Box( modifier = modifier @@ -319,84 +307,113 @@ fun Scrollbar( } }, ) { - val scrollbarThumbMovedDp = max( - a = with(localDensity) { thumbMovedPx.toDp() }, - b = 0.dp, - ) // scrollbar thumb container - Box( - modifier = Modifier - .align(Alignment.TopStart) - .run { - when (orientation) { - Orientation.Horizontal -> width(thumbSizeDp) - Orientation.Vertical -> height(thumbSizeDp) - } - } - .offset( - y = when (orientation) { - Orientation.Horizontal -> 0.dp - Orientation.Vertical -> scrollbarThumbMovedDp - }, - x = when (orientation) { - Orientation.Horizontal -> scrollbarThumbMovedDp - Orientation.Vertical -> 0.dp + Layout(content = { thumb() }) { measurables, constraints -> + val measurable = measurables.first() + + val thumbSizePx = max( + a = state.thumbSizePercent * track.size, + b = minThumbSize.toPx(), + ) + + val trackSizePx = when (state.thumbTrackSizePercent) { + 0f -> track.size + else -> (track.size - thumbSizePx) / state.thumbTrackSizePercent + } + + val thumbTravelPercent = max( + a = min( + a = when { + interactionThumbTravelPercent.isNaN() -> state.thumbMovedPercent + else -> interactionThumbTravelPercent }, + b = state.thumbTrackSizePercent, ), - ) { - thumb() + b = 0f, + ) + + 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 - // 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 - LaunchedEffect(pressedOffset) { - // Press ended, reset interactionThumbTravelPercent - if (pressedOffset == Offset.Unspecified) { - interactionThumbTravelPercent = Float.NaN - return@LaunchedEffect - } + LaunchedEffect(Unit) { + snapshotFlow { pressedOffset }.collect { pressedOffset -> + // Press ended, reset interactionThumbTravelPercent + if (pressedOffset == Offset.Unspecified) { + interactionThumbTravelPercent = Float.NaN + return@collect + } - var currentThumbMovedPercent = updatedState.thumbMovedPercent - val destinationThumbMovedPercent = track.thumbPosition( - dimension = orientation.valueOf(pressedOffset), - ) - val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent - val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f - - while (currentThumbMovedPercent != destinationThumbMovedPercent) { - currentThumbMovedPercent = when { - isPositive -> min( - a = currentThumbMovedPercent + delta, - b = destinationThumbMovedPercent, - ) + var currentThumbMovedPercent = state.thumbMovedPercent + val destinationThumbMovedPercent = track.thumbPosition( + dimension = orientation.valueOf(pressedOffset), + ) + val isPositive = currentThumbMovedPercent < destinationThumbMovedPercent + val delta = SCROLLBAR_PRESS_DELTA_PCT * if (isPositive) 1f else -1f + + while (currentThumbMovedPercent != destinationThumbMovedPercent) { + currentThumbMovedPercent = when { + isPositive -> min( + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, + ) - else -> max( - a = currentThumbMovedPercent + delta, - b = destinationThumbMovedPercent, - ) + else -> max( + a = currentThumbMovedPercent + delta, + b = destinationThumbMovedPercent, + ) + } + onThumbMoved(currentThumbMovedPercent) + interactionThumbTravelPercent = currentThumbMovedPercent + delay(SCROLLBAR_PRESS_DELAY_MS) } - onThumbMoved(currentThumbMovedPercent) - interactionThumbTravelPercent = currentThumbMovedPercent - delay(SCROLLBAR_PRESS_DELAY_MS) } } // Process drags - LaunchedEffect(draggedOffset) { - if (draggedOffset == Offset.Unspecified) { - interactionThumbTravelPercent = Float.NaN - return@LaunchedEffect + LaunchedEffect(Unit) { + snapshotFlow { draggedOffset }.collect { draggedOffset -> + if (draggedOffset == Offset.Unspecified) { + 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 } } diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt index 26f0bb2ae..a55f62f5f 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ScrollbarExt.kt @@ -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"); * 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.grid.LazyGridItemInfo 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.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]. @@ -33,29 +41,58 @@ import androidx.compose.runtime.Composable fun LazyListState.scrollbarState( itemsAvailable: Int, itemIndex: (LazyListItemInfo) -> Int = LazyListItemInfo::index, -): ScrollbarState = - scrollbarState( - itemsAvailable = itemsAvailable, - visibleItems = { layoutInfo.visibleItemsInfo }, - firstVisibleItemIndex = { visibleItems -> - interpolateFirstItemIndex( - visibleItems = visibleItems, - itemSize = { it.size }, - offset = { it.offset }, - nextItemOnMainAxis = { first -> visibleItems.find { it != first } }, - itemIndex = itemIndex, +): 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 = { 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, ) - }, - itemPercentVisible = itemPercentVisible@{ itemInfo -> - itemVisibilityPercentage( - itemSize = itemInfo.size, - itemStartOffset = itemInfo.offset, - viewportStartOffset = layoutInfo.viewportStartOffset, - viewportEndOffset = layoutInfo.viewportEndOffset, + scrollbarStateValue( + thumbSizePercent = thumbSizePercent, + thumbMovedPercent = when { + layoutInfo.reverseLayout -> 1f - thumbTravelPercent + else -> thumbTravelPercent + }, ) - }, - reverseLayout = { layoutInfo.reverseLayout }, - ) + } + .filterNotNull() + .distinctUntilChanged() + .collect { state.onScroll(it) } + } + return state +} /** * Calculates a [ScrollbarState] driven by the changes in a [LazyGridState] @@ -67,38 +104,136 @@ fun LazyListState.scrollbarState( fun LazyGridState.scrollbarState( itemsAvailable: Int, itemIndex: (LazyGridItemInfo) -> Int = LazyGridItemInfo::index, -): ScrollbarState = - scrollbarState( - itemsAvailable = itemsAvailable, - visibleItems = { layoutInfo.visibleItemsInfo }, - firstVisibleItemIndex = { visibleItems -> - interpolateFirstItemIndex( - visibleItems = visibleItems, - itemSize = { - 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 - } +): 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 - Orientation.Horizontal -> visibleItems.find { - it != first && it.column != first.column + val firstIndex = min( + 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 -> - itemVisibilityPercentage( - itemSize = layoutInfo.orientation.valueOf(itemInfo.size), - itemStartOffset = layoutInfo.orientation.valueOf(itemInfo.offset), - viewportStartOffset = layoutInfo.viewportStartOffset, - viewportEndOffset = layoutInfo.viewportEndOffset, + } + .filterNotNull() + .distinctUntilChanged() + .collect { state.onScroll(it) } + } + return state +} + +/** + * 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, ) - }, - reverseLayout = { layoutInfo.reverseLayout }, - ) + val thumbSizePercent = min( + a = itemsVisible / itemsAvailable, + b = 1f, + ) + scrollbarStateValue( + thumbSizePercent = thumbSizePercent, + thumbMovedPercent = thumbTravelPercent, + ) + } + .filterNotNull() + .distinctUntilChanged() + .collect { state.onScroll(it) } + } + return state +} + +private inline fun List.floatSumOf(selector: (T) -> Float): Float { + var sum = 0f + for (element in this) { + sum += selector(element) + } + return sum +} diff --git a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt index 4d187e269..a267ec2ec 100644 --- a/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt +++ b/core/designsystem/src/main/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/component/scrollbar/ThumbExt.kt @@ -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.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import kotlin.math.roundToInt /** * Remembers a function to react to [Scrollbar] thumb position displacements for a [LazyListState] @@ -50,6 +52,19 @@ fun LazyGridState.rememberDraggableScroller( 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. * @param itemsAvailable the total amount of items available to scroll in the layout. @@ -60,12 +75,12 @@ private inline fun rememberDraggableScroller( itemsAvailable: Int, crossinline scroll: suspend (index: Int) -> Unit, ): (Float) -> Unit { - var percentage by remember { mutableStateOf(Float.NaN) } + var percentage by remember { mutableFloatStateOf(Float.NaN) } val itemCount by rememberUpdatedState(itemsAvailable) LaunchedEffect(percentage) { if (percentage.isNaN()) return@LaunchedEffect - val indexToFind = (itemCount * percentage).toInt() + val indexToFind = (itemCount * percentage).roundToInt() scroll(indexToFind) } return remember { diff --git a/core/designsystem/src/main/res/drawable/ic_placeholder_default.xml b/core/designsystem/src/main/res/drawable/core_designsystem_ic_placeholder_default.xml similarity index 100% rename from core/designsystem/src/main/res/drawable/ic_placeholder_default.xml rename to core/designsystem/src/main/res/drawable/core_designsystem_ic_placeholder_default.xml diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt index d3349de80..e8cfd9a96 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/BackgroundScreenshotTests.kt @@ -36,7 +36,7 @@ import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class BackgroundScreenshotTests { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt index d3aa4224f..2f6ab5370 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/ButtonScreenshotTests.kt @@ -36,7 +36,7 @@ import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class ButtonScreenshotTests { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt index 3b2078c8a..2c9bc91ec 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/FilterChipScreenshotTests.kt @@ -43,7 +43,7 @@ import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class FilterChipScreenshotTests() { @@ -52,7 +52,7 @@ class FilterChipScreenshotTests() { @Test fun filterChip_multipleThemes() { - composeTestRule.captureMultiTheme("FilterChip") { description -> + composeTestRule.captureMultiTheme("FilterChip") { Surface { NiaFilterChip(selected = false, onSelectedChange = {}) { Text("Unselected chip") @@ -63,7 +63,7 @@ class FilterChipScreenshotTests() { @Test fun filterChip_multipleThemes_selected() { - composeTestRule.captureMultiTheme("FilterChip", "FilterChipSelected") { description -> + composeTestRule.captureMultiTheme("FilterChip", "FilterChipSelected") { Surface { NiaFilterChip(selected = true, onSelectedChange = {}) { Text("Selected Chip") diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt index 4d40f3bce..0104cfd47 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/IconButtonScreenshotTests.kt @@ -35,7 +35,7 @@ import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class IconButtonScreenshotTests { @@ -44,14 +44,14 @@ class IconButtonScreenshotTests { @Test fun iconButton_multipleThemes() { - composeTestRule.captureMultiTheme("IconButton") { description -> + composeTestRule.captureMultiTheme("IconButton") { NiaIconToggleExample(false) } } @Test fun iconButton_unchecked_multipleThemes() { - composeTestRule.captureMultiTheme("IconButton", "IconButtonUnchecked") { description -> + composeTestRule.captureMultiTheme("IconButton", "IconButtonUnchecked") { Surface { NiaIconToggleExample(true) } diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt index 412f42370..9f80d04e5 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/LoadingWheelScreenshotTests.kt @@ -37,7 +37,7 @@ import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class LoadingWheelScreenshotTests() { @@ -66,7 +66,7 @@ class LoadingWheelScreenshotTests() { fun loadingWheelAnimation() { composeTestRule.mainClock.autoAdvance = false composeTestRule.setContent { - NiaTheme() { + NiaTheme { NiaLoadingWheel(contentDesc = "") } } diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt index 05976ba47..e2e92b570 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/NavigationScreenshotTests.kt @@ -44,7 +44,7 @@ import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class NavigationScreenshotTests() { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt index 76117db2e..9190d5f35 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TabsScreenshotTests.kt @@ -42,7 +42,7 @@ import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class TabsScreenshotTests() { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt index 9db4d02a3..d9edfd6c6 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TagScreenshotTests.kt @@ -39,7 +39,7 @@ import org.robolectric.annotation.LooperMode @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class TagScreenshotTests() { diff --git a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt index 29404da79..6fac01562 100644 --- a/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt +++ b/core/designsystem/src/test/kotlin/com/google/samples/apps/nowinandroid/core/designsystem/TopAppBarScreenshotTests.kt @@ -43,7 +43,7 @@ import org.robolectric.annotation.LooperMode @OptIn(ExperimentalMaterial3Api::class) @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33], qualifiers = "480dpi") +@Config(application = HiltTestApplication::class, qualifiers = "480dpi") @LooperMode(LooperMode.Mode.PAUSED) class TopAppBarScreenshotTests() { diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 0e3949aa3..b81d62102 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.jacoco") - kotlin("kapt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.jacoco) + id("com.google.devtools.ksp") } android { @@ -24,13 +24,13 @@ android { } dependencies { - implementation(project(":core:data")) - implementation(project(":core:model")) + implementation(projects.core.data) + implementation(projects.core.model) implementation(libs.hilt.android) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) - kapt(libs.hilt.compiler) + ksp(libs.hilt.compiler) - testImplementation(project(":core:testing")) + testImplementation(projects.core.testing) } \ No newline at end of file diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index 55b49beb7..393e3aa7d 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -15,7 +15,7 @@ */ plugins { - id("nowinandroid.jvm.library") + alias(libs.plugins.nowinandroid.jvm.library) } dependencies { diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt index f130a70db..dcbcaa531 100644 --- a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt +++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/DarkThemeConfig.kt @@ -17,5 +17,7 @@ package com.google.samples.apps.nowinandroid.core.model.data enum class DarkThemeConfig { - FOLLOW_SYSTEM, LIGHT, DARK + FOLLOW_SYSTEM, + LIGHT, + DARK, } diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt index cef319c5f..ae1d525c5 100644 --- a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt +++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt @@ -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. */ -data class FollowableTopic( // TODO consider changing to UserTopic and flattening +// TODO consider changing to UserTopic and flattening +data class FollowableTopic( val topic: Topic, val isFollowed: Boolean, ) diff --git a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt index d8953df3c..431d4b573 100644 --- a/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt +++ b/core/model/src/main/kotlin/com/google/samples/apps/nowinandroid/core/model/data/ThemeBrand.kt @@ -17,5 +17,6 @@ package com.google.samples.apps.nowinandroid.core.model.data enum class ThemeBrand { - DEFAULT, ANDROID + DEFAULT, + ANDROID, } diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 633e2573d..dce97031f 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -15,9 +15,9 @@ */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.jacoco") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.jacoco) + alias(libs.plugins.nowinandroid.android.hilt) id("kotlinx-serialization") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } @@ -39,8 +39,8 @@ secrets { } dependencies { - implementation(project(":core:common")) - implementation(project(":core:model")) + implementation(projects.core.common) + implementation(projects.core.model) implementation(libs.coil.kt) implementation(libs.coil.kt.svg) implementation(libs.kotlinx.coroutines.android) @@ -50,5 +50,5 @@ dependencies { implementation(libs.retrofit.core) implementation(libs.retrofit.kotlin.serialization) - testImplementation(project(":core:testing")) + testImplementation(projects.core.testing) } diff --git a/core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt b/core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt index ee8534c10..a6162a9cc 100644 --- a/core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt +++ b/core/network/src/demo/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt @@ -28,5 +28,5 @@ import dagger.hilt.components.SingletonComponent interface FlavoredNetworkModule { @Binds - fun FakeNiaNetworkDataSource.binds(): NiaNetworkDataSource + fun binds(impl: FakeNiaNetworkDataSource): NiaNetworkDataSource } diff --git a/core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt b/core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt index ab463e99c..51a8a6f19 100644 --- a/core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt +++ b/core/network/src/prod/kotlin/com/google/samples/apps/nowinandroid/core/network/di/FlavoredNetworkModule.kt @@ -28,5 +28,5 @@ import dagger.hilt.components.SingletonComponent interface FlavoredNetworkModule { @Binds - fun RetrofitNiaNetwork.binds(): NiaNetworkDataSource + fun binds(impl: RetrofitNiaNetwork): NiaNetworkDataSource } diff --git a/core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSourceTest.kt b/core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSourceTest.kt index 76c2accf2..a0c60fdcb 100644 --- a/core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSourceTest.kt +++ b/core/network/src/test/kotlin/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSourceTest.kt @@ -44,10 +44,10 @@ class FakeNiaNetworkDataSourceTest { ) } + @Suppress("ktlint:standard:max-line-length") @Test fun testDeserializationOfTopics() = runTest(testDispatcher) { assertEquals( - /* ktlint-disable max-line-length */ NetworkTopic( id = "1", name = "Headlines", @@ -56,15 +56,14 @@ class FakeNiaNetworkDataSourceTest { 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", ), - /* ktlint-enable max-line-length */ subject.getTopics().first(), ) } + @Suppress("ktlint:standard:max-line-length") @Test fun testDeserializationOfNewsResources() = runTest(testDispatcher) { assertEquals( - /* ktlint-disable max-line-length */ NetworkNewsResource( id = "125", title = "Android Basics with Compose", @@ -83,7 +82,6 @@ class FakeNiaNetworkDataSourceTest { type = "Codelab", topics = listOf("2", "3", "10"), ), - /* ktlint-enable max-line-length */ subject.getNewsResources().find { it.id == "125" }, ) } diff --git a/core/notifications/build.gradle.kts b/core/notifications/build.gradle.kts index 012c6f3f3..31b15a805 100644 --- a/core/notifications/build.gradle.kts +++ b/core/notifications/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.hilt) } android { @@ -24,8 +24,8 @@ android { } dependencies { - implementation(project(":core:common")) - implementation(project(":core:model")) + implementation(projects.core.common) + implementation(projects.core.model) implementation(libs.kotlinx.coroutines.android) implementation(libs.androidx.browser) diff --git a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt index b7fcc9b26..79034524a 100644 --- a/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt +++ b/core/notifications/src/main/kotlin/com/google/samples/apps/nowinandroid/core/notifications/SystemTrayNotifier.kt @@ -72,7 +72,7 @@ class SystemTrayNotifier @Inject constructor( .map { newsResource -> createNewsNotification { setSmallIcon( - com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, + com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification, ) .setContentTitle(newsResource.title) .setContentText(newsResource.content) @@ -83,13 +83,13 @@ class SystemTrayNotifier @Inject constructor( } val summaryNotification = createNewsNotification { val title = getString( - R.string.news_notification_group_summary, + R.string.core_notifications_news_notification_group_summary, truncatedNewsResources.size, ) setContentTitle(title) .setContentText(title) .setSmallIcon( - com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, + com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification, ) // Build summary info into InboxStyle template. .setStyle(newsNotificationStyle(truncatedNewsResources, title)) @@ -141,17 +141,17 @@ private fun Context.createNewsNotification( } /** - * Ensures the a notification channel is is present if applicable + * Ensures that a notification channel is present if applicable */ private fun Context.ensureNotificationChannelExists() { if (VERSION.SDK_INT < VERSION_CODES.O) return val channel = NotificationChannel( NEWS_NOTIFICATION_CHANNEL_ID, - getString(R.string.news_notification_channel_name), + getString(R.string.core_notifications_news_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT, ).apply { - description = getString(R.string.news_notification_channel_description) + description = getString(R.string.core_notifications_news_notification_channel_description) } // Register the channel with the system NotificationManagerCompat.from(this).createNotificationChannel(channel) diff --git a/core/notifications/src/main/res/values/strings.xml b/core/notifications/src/main/res/values/strings.xml index 5bb37b23a..88e492e77 100644 --- a/core/notifications/src/main/res/values/strings.xml +++ b/core/notifications/src/main/res/values/strings.xml @@ -15,7 +15,7 @@ limitations under the License. --> - News updates - The latest updates on what\'s new in Android - %1$d news updates + News updates + The latest updates on what\'s new in Android + %1$d news updates diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 51f21355e..8ad91a0d5 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.hilt) } android { @@ -40,12 +40,12 @@ dependencies { debugApi(libs.androidx.compose.ui.testManifest) - implementation(project(":core:common")) - implementation(project(":core:data")) - implementation(project(":core:designsystem")) - implementation(project(":core:domain")) - implementation(project(":core:model")) - implementation(project(":core:notifications")) - implementation(project(":core:analytics")) + implementation(projects.core.common) + implementation(projects.core.data) + implementation(projects.core.designsystem) + implementation(projects.core.domain) + implementation(projects.core.model) + implementation(projects.core.notifications) + implementation(projects.core.analytics) implementation(libs.kotlinx.datetime) } diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt index a96326a62..9b85516e7 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt @@ -14,12 +14,13 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.testing.data import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic -/* ktlint-disable max-line-length */ val followableTopicTestData: List = listOf( FollowableTopic( topic = Topic( diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt index ef845dc58..b3fff7ca0 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/NewsResourcesTestData.kt @@ -14,12 +14,13 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.testing.data import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import kotlinx.datetime.Instant -/* ktlint-disable max-line-length */ val newsResourcesTestData: List = listOf( NewsResource( id = "1", diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt index 90e9166a5..fc6ef62a6 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/TopicsTestData.kt @@ -14,11 +14,12 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.testing.data import com.google.samples.apps.nowinandroid.core.model.data.Topic -/* ktlint-disable max-line-length */ val topicsTestData: List = listOf( Topic( id = "2", diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt index 97acad088..4174391df 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.testing.data import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig @@ -26,7 +28,6 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant -/* ktlint-disable max-line-length */ val userNewsResourcesTestData: List = UserData( bookmarkedNewsResources = setOf("1", "4"), viewedNewsResources = setOf("1", "2", "4"), diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt index 5b8807b75..28155f5ad 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/MainDispatcherRule.kt @@ -30,7 +30,7 @@ import org.junit.runner.Description * for the duration of the test. */ class MainDispatcherRule( - val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(), ) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(testDispatcher) diff --git a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt index e84fe7d33..0f00ff16d 100644 --- a/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt +++ b/core/testing/src/main/kotlin/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt @@ -38,8 +38,10 @@ import org.robolectric.RuntimeEnvironment val DefaultRoborazziOptions = RoborazziOptions( - compareOptions = CompareOptions(changeThreshold = 0f), // Pixel-perfect matching - recordOptions = RecordOptions(resizeScale = 0.5), // Reduce the size of the PNGs + // Pixel-perfect matching + compareOptions = CompareOptions(changeThreshold = 0f), + // Reduce the size of the PNGs + recordOptions = RecordOptions(resizeScale = 0.5), ) enum class DefaultTestDevices(val description: String, val spec: String) { diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 044abedaf..c9527d09e 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) } android { @@ -40,10 +40,10 @@ dependencies { debugApi(libs.androidx.compose.ui.tooling) - implementation(project(":core:analytics")) - implementation(project(":core:designsystem")) - implementation(project(":core:domain")) - implementation(project(":core:model")) + implementation(projects.core.analytics) + implementation(projects.core.designsystem) + implementation(projects.core.domain) + implementation(projects.core.model) implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) implementation(libs.androidx.core.ktx) @@ -51,5 +51,5 @@ dependencies { implementation(libs.coil.kt.compose) implementation(libs.kotlinx.datetime) - androidTestImplementation(project(":core:testing")) + androidTestImplementation(projects.core.testing) } diff --git a/core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt b/core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt index a2fdbaee1..d0a124316 100644 --- a/core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt +++ b/core/ui/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt @@ -52,7 +52,7 @@ class NewsResourceCardTest { composeTestRule .onNodeWithText( composeTestRule.activity.getString( - R.string.card_meta_data_text, + R.string.core_ui_card_meta_data_text, dateFormatted, newsWithKnownResourceType.type, ), @@ -123,7 +123,7 @@ class NewsResourceCardTest { composeTestRule .onNodeWithContentDescription( composeTestRule.activity.getString( - R.string.unread_resource_dot_content_description, + R.string.core_ui_unread_resource_dot_content_description, ), ) .assertIsDisplayed() @@ -147,7 +147,7 @@ class NewsResourceCardTest { composeTestRule .onNodeWithContentDescription( composeTestRule.activity.getString( - R.string.unread_resource_dot_content_description, + R.string.core_ui_unread_resource_dot_content_description, ), ) .assertDoesNotExist() diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt index 2132f5b16..16ae1eced 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt @@ -14,13 +14,14 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic -/* ktlint-disable max-line-length */ /** * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) * provides list of [FollowableTopic] for Composable previews. diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 4a9f5d7c9..afdb584a2 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -21,17 +21,15 @@ import android.net.Uri import androidx.annotation.ColorInt import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyGridScope -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext @@ -47,7 +45,8 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource * An extension on [LazyListScope] defining a feed with news resources. * Depending on the [feedState], this might emit no items. */ -fun LazyGridScope.newsFeed( +@OptIn(ExperimentalFoundationApi::class) +fun LazyStaggeredGridScope.newsFeed( feedState: NewsFeedUiState, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit, @@ -62,9 +61,6 @@ fun LazyGridScope.newsFeed( key = { it.id }, contentType = { "newsFeedItem" }, ) { userNewsResource -> - val resourceUrl by remember { - mutableStateOf(Uri.parse(userNewsResource.url)) - } val context = LocalContext.current val analyticsHelper = LocalAnalyticsHelper.current val backgroundColor = MaterialTheme.colorScheme.background.toArgb() @@ -77,7 +73,8 @@ fun LazyGridScope.newsFeed( analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, ) - launchCustomChromeTab(context, resourceUrl, backgroundColor) + launchCustomChromeTab(context, Uri.parse(userNewsResource.url), backgroundColor) + onNewsResourceViewed(userNewsResource.id) }, hasBeenViewed = userNewsResource.hasBeenViewed, @@ -88,7 +85,9 @@ fun LazyGridScope.newsFeed( ) }, onTopicClick = onTopicClick, - modifier = Modifier.padding(horizontal = 8.dp), + modifier = Modifier + .padding(horizontal = 8.dp) + .animateItemPlacement(), ) } } @@ -129,7 +128,7 @@ sealed interface NewsFeedUiState { @Composable private fun NewsFeedLoadingPreview() { NiaTheme { - LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) { + LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Adaptive(300.dp)) { newsFeed( feedState = NewsFeedUiState.Loading, onNewsResourcesCheckedChanged = { _, _ -> }, @@ -148,7 +147,7 @@ private fun NewsFeedContentPreview( userNewsResources: List, ) { NiaTheme { - LazyVerticalGrid(columns = GridCells.Adaptive(300.dp)) { + LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Adaptive(300.dp)) { newsFeed( feedState = NewsFeedUiState.Success(userNewsResources), onNewsResourcesCheckedChanged = { _, _ -> }, diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index de4aec9d7..9eca6b141 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -91,7 +91,7 @@ fun NewsResourceCardExpanded( onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { - val clickActionLabel = stringResource(R.string.card_tap_action) + val clickActionLabel = stringResource(R.string.core_ui_card_tap_action) Card( onClick = onClick, shape = RoundedCornerShape(16.dp), @@ -183,10 +183,11 @@ fun NewsResourceHeaderImage( painter = if (isError.not() && !isLocalInspection) { imageLoader } else { - painterResource(drawable.ic_placeholder_default) + painterResource(drawable.core_designsystem_ic_placeholder_default) }, // TODO b/226661685: Investigate using alt text of image to populate content description - contentDescription = null, // decorative image, + // decorative image, + contentDescription = null, ) } } @@ -212,13 +213,13 @@ fun BookmarkButton( icon = { Icon( imageVector = NiaIcons.BookmarkBorder, - contentDescription = stringResource(R.string.bookmark), + contentDescription = stringResource(R.string.core_ui_bookmark), ) }, checkedIcon = { Icon( imageVector = NiaIcons.Bookmark, - contentDescription = stringResource(R.string.unbookmark), + contentDescription = stringResource(R.string.core_ui_unbookmark), ) }, ) @@ -229,7 +230,7 @@ fun NotificationDot( color: Color, modifier: Modifier = Modifier, ) { - val description = stringResource(R.string.unread_resource_dot_content_description) + val description = stringResource(R.string.core_ui_unread_resource_dot_content_description) Canvas( modifier = modifier .semantics { contentDescription = description }, @@ -273,7 +274,7 @@ fun NewsResourceMetaData( val formattedDate = dateFormatted(publishDate) Text( if (resourceType.isNotBlank()) { - stringResource(R.string.card_meta_data_text, formattedDate, resourceType) + stringResource(R.string.core_ui_card_meta_data_text, formattedDate, resourceType) } else { formattedDate }, @@ -295,7 +296,8 @@ fun NewsResourceTopics( modifier: Modifier = Modifier, ) { Row( - modifier = modifier.horizontalScroll(rememberScrollState()), // causes narrow chips + // causes narrow chips + modifier = modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { for (followableTopic in topics) { @@ -305,12 +307,12 @@ fun NewsResourceTopics( text = { val contentDescription = if (followableTopic.isFollowed) { stringResource( - R.string.topic_chip_content_description_when_followed, + R.string.core_ui_topic_chip_content_description_when_followed, followableTopic.topic.name, ) } else { stringResource( - R.string.topic_chip_content_description_when_not_followed, + R.string.core_ui_topic_chip_content_description_when_not_followed, followableTopic.topic.name, ) } diff --git a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index 493788314..3189e8403 100644 --- a/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/kotlin/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -29,7 +31,6 @@ import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.toInstant -/* ktlint-disable max-line-length */ /** * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) * provides list of [UserNewsResource] for Composable previews. diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index d21a5ea36..65a855fc9 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -15,15 +15,15 @@ limitations under the License. --> - Bookmark - Unbookmark - Back + Bookmark + Unbookmark + Back - Unread + Unread - Open Resource Link - %1$s • %2$s + Open Resource Link + %1$s • %2$s - %1$s is followed - %1$s is not followed + %1$s is followed + %1$s is not followed diff --git a/feature/bookmarks/build.gradle.kts b/feature/bookmarks/build.gradle.kts index 667e674ec..32394f911 100644 --- a/feature/bookmarks/build.gradle.kts +++ b/feature/bookmarks/build.gradle.kts @@ -15,9 +15,9 @@ */ plugins { - id("nowinandroid.android.feature") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) } android { diff --git a/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt index 6e432f2ab..3d684f9d1 100644 --- a/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt +++ b/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt @@ -59,7 +59,7 @@ class BookmarksScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.saved_loading), + composeTestRule.activity.resources.getString(R.string.feature_bookmarks_loading), ) .assertExists() } @@ -125,7 +125,7 @@ class BookmarksScreenTest { composeTestRule .onAllNodesWithContentDescription( composeTestRule.activity.getString( - com.google.samples.apps.nowinandroid.core.ui.R.string.unbookmark, + com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_unbookmark, ), ).filter( hasAnyAncestor( @@ -156,13 +156,13 @@ class BookmarksScreenTest { composeTestRule .onNodeWithText( - composeTestRule.activity.getString(R.string.bookmarks_empty_error), + composeTestRule.activity.getString(R.string.feature_bookmarks_empty_error), ) .assertExists() composeTestRule .onNodeWithText( - composeTestRule.activity.getString(R.string.bookmarks_empty_description), + composeTestRule.activity.getString(R.string.feature_bookmarks_empty_description), ) .assertExists() } diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index e46ada015..d316b0bfd 100644 --- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -35,10 +35,10 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.lazy.grid.GridCells.Adaptive -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -113,8 +113,8 @@ internal fun BookmarksScreen( undoBookmarkRemoval: () -> Unit = {}, clearUndoState: () -> Unit = {}, ) { - val bookmarkRemovedMessage = stringResource(id = R.string.bookmark_removed) - val undoText = stringResource(id = R.string.undo) + val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_removed) + val undoText = stringResource(id = R.string.feature_bookmarks_undo) LaunchedEffect(shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) { @@ -163,7 +163,7 @@ private fun LoadingState(modifier: Modifier = Modifier) { .fillMaxWidth() .wrapContentSize() .testTag("forYou:loading"), - contentDesc = stringResource(id = R.string.saved_loading), + contentDesc = stringResource(id = R.string.feature_bookmarks_loading), ) } @@ -175,17 +175,17 @@ private fun BookmarksGrid( onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { - val scrollableState = rememberLazyGridState() + val scrollableState = rememberLazyStaggeredGridState() TrackScrollJank(scrollableState = scrollableState, stateName = "bookmarks:grid") Box( modifier = modifier .fillMaxSize(), ) { - LazyVerticalGrid( - columns = Adaptive(300.dp), + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(300.dp), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), + verticalItemSpacing = 24.dp, state = scrollableState, modifier = Modifier .fillMaxSize() @@ -197,7 +197,7 @@ private fun BookmarksGrid( onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) - item(span = { GridItemSpan(maxLineSpan) }) { + item(span = StaggeredGridItemSpan.FullLine) { Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) } } @@ -236,7 +236,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { val iconTint = LocalTintTheme.current.iconTint Image( modifier = Modifier.fillMaxWidth(), - painter = painterResource(id = R.drawable.img_empty_bookmarks), + painter = painterResource(id = R.drawable.feature_bookmarks_img_empty_bookmarks), colorFilter = if (iconTint != null) ColorFilter.tint(iconTint) else null, contentDescription = null, ) @@ -244,7 +244,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(48.dp)) Text( - text = stringResource(id = R.string.bookmarks_empty_error), + text = stringResource(id = R.string.feature_bookmarks_empty_error), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium, @@ -254,7 +254,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(id = R.string.bookmarks_empty_description), + text = stringResource(id = R.string.feature_bookmarks_empty_description), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt index ebcde4ab1..81fa114e2 100644 --- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt +++ b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt @@ -22,17 +22,17 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute -const val bookmarksRoute = "bookmarks_route" +const val BOOKMARKS_ROUTE = "bookmarks_route" fun NavController.navigateToBookmarks(navOptions: NavOptions? = null) { - this.navigate(bookmarksRoute, navOptions) + this.navigate(BOOKMARKS_ROUTE, navOptions) } fun NavGraphBuilder.bookmarksScreen( onTopicClick: (String) -> Unit, onShowSnackbar: suspend (String, String?) -> Boolean, ) { - composable(route = bookmarksRoute) { + composable(route = BOOKMARKS_ROUTE) { BookmarksRoute(onTopicClick, onShowSnackbar) } } diff --git a/feature/bookmarks/src/main/res/drawable/img_empty_bookmarks.xml b/feature/bookmarks/src/main/res/drawable/feature_bookmarks_img_empty_bookmarks.xml similarity index 100% rename from feature/bookmarks/src/main/res/drawable/img_empty_bookmarks.xml rename to feature/bookmarks/src/main/res/drawable/feature_bookmarks_img_empty_bookmarks.xml diff --git a/feature/bookmarks/src/main/res/values/strings.xml b/feature/bookmarks/src/main/res/values/strings.xml index 875a90a0b..6e2b23043 100644 --- a/feature/bookmarks/src/main/res/values/strings.xml +++ b/feature/bookmarks/src/main/res/values/strings.xml @@ -15,10 +15,10 @@ limitations under the License. --> - Saved - Loading saved… - No saved updates - Updates you save will be stored here\nto read later - Bookmark removed - UNDO + Saved + Loading saved… + No saved updates + Updates you save will be stored here\nto read later + Bookmark removed + UNDO diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index bd633e3d2..afe769ab8 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -15,9 +15,9 @@ */ plugins { - id("nowinandroid.android.feature") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) } android { diff --git a/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index 7431555ba..5477493ef 100644 --- a/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -45,7 +45,7 @@ class ForYouScreenTest { private val doneButtonMatcher by lazy { hasText( - composeTestRule.activity.resources.getString(R.string.done), + composeTestRule.activity.resources.getString(R.string.feature_foryou_done), ) } @@ -70,7 +70,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.for_you_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), ) .assertExists() } @@ -96,7 +96,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.for_you_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), ) .assertExists() } @@ -215,7 +215,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.for_you_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), ) .assertExists() } @@ -241,7 +241,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.for_you_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), ) .assertExists() } diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index a24a91f1a..30134715b 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -49,13 +49,14 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridCells.Adaptive -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -153,7 +154,7 @@ internal fun ForYouScreen( val itemsAvailable = feedItemsSize(feedState, onboardingUiState) - val state = rememberLazyGridState() + val state = rememberLazyStaggeredGridState() val scrollbarState = state.scrollbarState( itemsAvailable = itemsAvailable, ) @@ -163,11 +164,11 @@ internal fun ForYouScreen( modifier = modifier .fillMaxSize(), ) { - LazyVerticalGrid( - columns = Adaptive(300.dp), + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(300.dp), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), + verticalItemSpacing = 24.dp, modifier = Modifier .testTag("forYou:feed"), state = state, @@ -197,7 +198,7 @@ internal fun ForYouScreen( onTopicClick = onTopicClick, ) - item(span = { GridItemSpan(maxLineSpan) }, contentType = "bottomSpacing") { + item(span = StaggeredGridItemSpan.FullLine, contentType = "bottomSpacing") { Column { Spacer(modifier = Modifier.height(8.dp)) // Add space for the content to clear the "offline" snackbar. @@ -216,7 +217,7 @@ internal fun ForYouScreen( targetOffsetY = { fullHeight -> -fullHeight }, ) + fadeOut(), ) { - val loadingContentDescription = stringResource(id = R.string.for_you_loading) + val loadingContentDescription = stringResource(id = R.string.feature_foryou_loading) Box( modifier = Modifier .fillMaxWidth() @@ -255,7 +256,7 @@ internal fun ForYouScreen( * Depending on the [onboardingUiState], this might emit no items. * */ -private fun LazyGridScope.onboarding( +private fun LazyStaggeredGridScope.onboarding( onboardingUiState: OnboardingUiState, onTopicCheckedChanged: (String, Boolean) -> Unit, saveFollowedTopics: () -> Unit, @@ -268,10 +269,10 @@ private fun LazyGridScope.onboarding( -> Unit is OnboardingUiState.Shown -> { - item(span = { GridItemSpan(maxLineSpan) }, contentType = "onboarding") { + item(span = StaggeredGridItemSpan.FullLine, contentType = "onboarding") { Column(modifier = interestsItemModifier) { Text( - text = stringResource(R.string.onboarding_guidance_title), + text = stringResource(R.string.feature_foryou_onboarding_guidance_title), textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() @@ -279,7 +280,7 @@ private fun LazyGridScope.onboarding( style = MaterialTheme.typography.titleMedium, ) Text( - text = stringResource(R.string.onboarding_guidance_subtitle), + text = stringResource(R.string.feature_foryou_onboarding_guidance_subtitle), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp, start = 24.dp, end = 24.dp), @@ -305,7 +306,7 @@ private fun LazyGridScope.onboarding( .fillMaxWidth(), ) { Text( - text = stringResource(R.string.done), + text = stringResource(R.string.feature_foryou_done), ) } } @@ -434,9 +435,10 @@ fun TopicIcon( modifier: Modifier = Modifier, ) { DynamicAsyncImage( - placeholder = painterResource(R.drawable.ic_icon_placeholder), + placeholder = painterResource(R.drawable.feature_foryou_ic_icon_placeholder), imageUrl = imageUrl, - contentDescription = null, // decorative + // decorative + contentDescription = null, modifier = modifier .padding(10.dp) .size(32.dp), diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt index 705495cc2..154b0f83b 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt +++ b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt @@ -26,17 +26,17 @@ import androidx.navigation.navDeepLink import com.google.samples.apps.nowinandroid.feature.foryou.ForYouRoute const val LINKED_NEWS_RESOURCE_ID = "linkedNewsResourceId" -const val forYouNavigationRoute = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}" +const val FOR_YOU_ROUTE = "for_you_route/{$LINKED_NEWS_RESOURCE_ID}" private const val DEEP_LINK_URI_PATTERN = "https://www.nowinandroid.apps.samples.google.com/foryou/{$LINKED_NEWS_RESOURCE_ID}" fun NavController.navigateToForYou(navOptions: NavOptions? = null) { - this.navigate(forYouNavigationRoute, navOptions) + this.navigate(FOR_YOU_ROUTE, navOptions) } fun NavGraphBuilder.forYouScreen(onTopicClick: (String) -> Unit) { composable( - route = forYouNavigationRoute, + route = FOR_YOU_ROUTE, deepLinks = listOf( navDeepLink { uriPattern = DEEP_LINK_URI_PATTERN }, ), diff --git a/feature/foryou/src/main/res/drawable/ic_icon_placeholder.xml b/feature/foryou/src/main/res/drawable/feature_foryou_ic_icon_placeholder.xml similarity index 100% rename from feature/foryou/src/main/res/drawable/ic_icon_placeholder.xml rename to feature/foryou/src/main/res/drawable/feature_foryou_ic_icon_placeholder.xml diff --git a/feature/foryou/src/main/res/values/strings.xml b/feature/foryou/src/main/res/values/strings.xml index 5a33bc9c8..166749664 100644 --- a/feature/foryou/src/main/res/values/strings.xml +++ b/feature/foryou/src/main/res/values/strings.xml @@ -15,11 +15,11 @@ limitations under the License. --> - For you - Done - Loading for you… - Navigate up - What are you interested in? - Updates from topics you follow will appear here. Follow some things to get started. + For you + Done + Loading for you… + Navigate up + What are you interested in? + Updates from topics you follow will appear here. Follow some things to get started. diff --git a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt index 1c521d419..14b67c64e 100644 --- a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt +++ b/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt @@ -46,7 +46,7 @@ import java.util.TimeZone */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class, sdk = [33]) +@Config(application = HiltTestApplication::class) @LooperMode(LooperMode.Mode.PAUSED) class ForYouScreenScreenshotTests { diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png index a66604bc6..deb0cd855 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png index 54ae3be02..a12c429d9 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png differ diff --git a/feature/interests/build.gradle.kts b/feature/interests/build.gradle.kts index 5c4b0360a..20b1ef1aa 100644 --- a/feature/interests/build.gradle.kts +++ b/feature/interests/build.gradle.kts @@ -15,9 +15,9 @@ */ plugins { - id("nowinandroid.android.feature") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) } android { namespace = "com.google.samples.apps.nowinandroid.feature.interests" diff --git a/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt b/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt index 492e91fa3..4f9cbcc04 100644 --- a/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt +++ b/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt @@ -50,12 +50,12 @@ class InterestsScreenTest { @Before fun setup() { composeTestRule.activity.apply { - interestsLoading = getString(R.string.loading) - interestsEmptyHeader = getString(R.string.empty_header) + interestsLoading = getString(R.string.feature_interests_loading) + interestsEmptyHeader = getString(R.string.feature_interests_empty_header) interestsTopicCardFollowButton = - getString(R.string.card_follow_button_content_desc) + getString(R.string.feature_interests_card_follow_button_content_desc) interestsTopicCardUnfollowButton = - getString(R.string.card_unfollow_button_content_desc) + getString(R.string.feature_interests_card_unfollow_button_content_desc) } } diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt index 7456ba92b..b055a3a14 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt @@ -68,7 +68,7 @@ fun InterestsItem( Icon( imageVector = NiaIcons.Add, contentDescription = stringResource( - id = string.card_follow_button_content_desc, + id = string.feature_interests_card_follow_button_content_desc, ), ) }, @@ -76,7 +76,7 @@ fun InterestsItem( Icon( imageVector = NiaIcons.Check, contentDescription = stringResource( - id = string.card_unfollow_button_content_desc, + id = string.feature_interests_card_unfollow_button_content_desc, ), ) }, @@ -99,7 +99,8 @@ private fun InterestsIcon(topicImageUrl: String, modifier: Modifier = Modifier) .background(MaterialTheme.colorScheme.surface) .padding(4.dp), imageVector = NiaIcons.Person, - contentDescription = null, // decorative image + // decorative image + contentDescription = null, ) } else { DynamicAsyncImage( diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index e618c1c9f..5944b8631 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -65,7 +65,7 @@ internal fun InterestsScreen( InterestsUiState.Loading -> NiaLoadingWheel( modifier = modifier, - contentDesc = stringResource(id = R.string.loading), + contentDesc = stringResource(id = R.string.feature_interests_loading), ) is InterestsUiState.Interests -> TopicsTabContent( @@ -82,7 +82,7 @@ internal fun InterestsScreen( @Composable private fun InterestsEmptyScreen() { - Text(text = stringResource(id = R.string.empty_header)) + Text(text = stringResource(id = R.string.feature_interests_empty_header)) } @DevicePreviews diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt index 7558ec48d..831247e27 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt +++ b/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt @@ -24,7 +24,7 @@ import androidx.navigation.navigation import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute private const val INTERESTS_GRAPH_ROUTE_PATTERN = "interests_graph" -const val interestsRoute = "interests_route" +const val INTERESTS_ROUTE = "interests_route" fun NavController.navigateToInterestsGraph(navOptions: NavOptions? = null) { this.navigate(INTERESTS_GRAPH_ROUTE_PATTERN, navOptions) @@ -36,9 +36,9 @@ fun NavGraphBuilder.interestsGraph( ) { navigation( route = INTERESTS_GRAPH_ROUTE_PATTERN, - startDestination = interestsRoute, + startDestination = INTERESTS_ROUTE, ) { - composable(route = interestsRoute) { + composable(route = INTERESTS_ROUTE) { InterestsRoute(onTopicClick) } nestedGraphs() diff --git a/feature/interests/src/main/res/values/strings.xml b/feature/interests/src/main/res/values/strings.xml index 384cb1deb..2dd1c18a9 100644 --- a/feature/interests/src/main/res/values/strings.xml +++ b/feature/interests/src/main/res/values/strings.xml @@ -15,9 +15,9 @@ limitations under the License. --> - Interests - Loading data - "No available data" - Follow interest - Unfollow interest + Interests + Loading data + "No available data" + Follow interest + Unfollow interest diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index cbaa767bc..d96f290e3 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -15,9 +15,9 @@ */ plugins { - id("nowinandroid.android.feature") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) } android { @@ -25,9 +25,9 @@ android { } dependencies { - implementation(project(":feature:bookmarks")) - implementation(project(":feature:foryou")) - implementation(project(":feature:interests")) + implementation(projects.feature.bookmarks) + implementation(projects.feature.foryou) + implementation(projects.feature.interests) implementation(libs.kotlinx.datetime) } diff --git a/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt b/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt index d6c07221e..8a0532e1b 100644 --- a/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt +++ b/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt @@ -70,17 +70,17 @@ class SearchScreenTest { @Before fun setup() { composeTestRule.activity.apply { - clearSearchContentDesc = getString(R.string.clear_search_text_content_desc) - clearRecentSearchesContentDesc = getString(R.string.clear_recent_searches_content_desc) + clearSearchContentDesc = getString(R.string.feature_search_clear_search_text_content_desc) + clearRecentSearchesContentDesc = getString(R.string.feature_search_clear_recent_searches_content_desc) followButtonContentDesc = - getString(interestsR.string.card_follow_button_content_desc) + getString(interestsR.string.feature_interests_card_follow_button_content_desc) unfollowButtonContentDesc = - getString(interestsR.string.card_unfollow_button_content_desc) - topicsString = getString(R.string.topics) - updatesString = getString(R.string.updates) - tryAnotherSearchString = getString(R.string.try_another_search) + - " " + getString(R.string.interests) + " " + getString(R.string.to_browse_topics) - searchNotReadyString = getString(R.string.search_not_ready) + getString(interestsR.string.feature_interests_card_unfollow_button_content_desc) + topicsString = getString(R.string.feature_search_topics) + updatesString = getString(R.string.feature_search_updates) + tryAnotherSearchString = getString(R.string.feature_search_try_another_search) + + " " + getString(R.string.feature_search_interests) + " " + getString(R.string.feature_search_to_browse_topics) + searchNotReadyString = getString(R.string.feature_search_not_ready) } } diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt index 944d17630..95e5514fe 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -35,11 +35,11 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells.Adaptive -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.KeyboardActions @@ -217,7 +217,7 @@ fun EmptySearchResultBody( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 48.dp), ) { - val message = stringResource(id = searchR.string.search_result_not_found, searchQuery) + val message = stringResource(id = searchR.string.feature_search_result_not_found, searchQuery) val start = message.indexOf(searchQuery) Text( text = AnnotatedString( @@ -234,9 +234,9 @@ fun EmptySearchResultBody( textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 24.dp), ) - val interests = stringResource(id = searchR.string.interests) + val interests = stringResource(id = searchR.string.feature_search_interests) val tryAnotherSearchString = buildAnnotatedString { - append(stringResource(id = searchR.string.try_another_search)) + append(stringResource(id = searchR.string.feature_search_try_another_search)) append(" ") withStyle( style = SpanStyle( @@ -248,7 +248,7 @@ fun EmptySearchResultBody( append(interests) } append(" ") - append(stringResource(id = searchR.string.to_browse_topics)) + append(stringResource(id = searchR.string.feature_search_to_browse_topics)) } ClickableText( text = tryAnotherSearchString, @@ -278,7 +278,7 @@ private fun SearchNotReadyBody() { modifier = Modifier.padding(horizontal = 48.dp), ) { Text( - text = stringResource(id = searchR.string.search_not_ready), + text = stringResource(id = searchR.string.feature_search_not_ready), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 24.dp), @@ -297,16 +297,16 @@ private fun SearchResultBody( onTopicClick: (String) -> Unit, searchQuery: String = "", ) { - val state = rememberLazyGridState() + val state = rememberLazyStaggeredGridState() Box( modifier = Modifier .fillMaxSize(), ) { - LazyVerticalGrid( - columns = Adaptive(300.dp), + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(300.dp), contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp), + verticalItemSpacing = 24.dp, modifier = Modifier .fillMaxSize() .testTag("search:newsResources"), @@ -314,14 +314,12 @@ private fun SearchResultBody( ) { if (topics.isNotEmpty()) { item( - span = { - GridItemSpan(maxLineSpan) - }, + span = StaggeredGridItemSpan.FullLine, ) { Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.topics)) + append(stringResource(id = searchR.string.feature_search_topics)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -330,10 +328,9 @@ private fun SearchResultBody( topics.forEach { followableTopic -> val topicId = followableTopic.topic.id item( - key = "topic-$topicId", // Append a prefix to distinguish a key for news resources - span = { - GridItemSpan(maxLineSpan) - }, + // Append a prefix to distinguish a key for news resources + key = "topic-$topicId", + span = StaggeredGridItemSpan.FullLine, ) { InterestsItem( name = followableTopic.topic.name, @@ -353,14 +350,12 @@ private fun SearchResultBody( if (newsResources.isNotEmpty()) { item( - span = { - GridItemSpan(maxLineSpan) - }, + span = StaggeredGridItemSpan.FullLine, ) { Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.updates)) + append(stringResource(id = searchR.string.feature_search_updates)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -412,7 +407,7 @@ private fun RecentSearchesBody( Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.recent_searches)) + append(stringResource(id = searchR.string.feature_search_recent_searches)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -427,7 +422,7 @@ private fun RecentSearchesBody( Icon( imageVector = NiaIcons.Close, contentDescription = stringResource( - id = searchR.string.clear_recent_searches_content_desc, + id = searchR.string.feature_search_clear_recent_searches_content_desc, ), tint = MaterialTheme.colorScheme.onSurface, ) @@ -465,7 +460,7 @@ private fun SearchToolbar( Icon( imageVector = NiaIcons.ArrowBack, contentDescription = stringResource( - id = string.back, + id = string.core_ui_back, ), ) } @@ -502,7 +497,7 @@ private fun SearchTextField( Icon( imageVector = NiaIcons.Search, contentDescription = stringResource( - id = searchR.string.search, + id = searchR.string.feature_search_title, ), tint = MaterialTheme.colorScheme.onSurface, ) @@ -517,7 +512,7 @@ private fun SearchTextField( Icon( imageVector = NiaIcons.Close, contentDescription = stringResource( - id = searchR.string.clear_search_text_content_desc, + id = searchR.string.feature_search_clear_search_text_content_desc, ), tint = MaterialTheme.colorScheme.onSurface, ) diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt index 4268893da..257d8b68e 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt +++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.feature.search import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -21,7 +23,6 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics -/* ktlint-disable max-line-length */ /** * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) * provides list of [SearchResultUiState] for Composable previews. diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt index 42bf3f475..a449600b2 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt +++ b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt @@ -22,10 +22,10 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.google.samples.apps.nowinandroid.feature.search.SearchRoute -const val searchRoute = "search_route" +const val SEARCH_ROUTE = "search_route" fun NavController.navigateToSearch(navOptions: NavOptions? = null) { - this.navigate(searchRoute, navOptions) + this.navigate(SEARCH_ROUTE, navOptions) } fun NavGraphBuilder.searchScreen( @@ -35,7 +35,7 @@ fun NavGraphBuilder.searchScreen( ) { // TODO: Handle back stack for each top-level destination. At the moment each top-level // destination may have own search screen's back stack. - composable(route = searchRoute) { + composable(route = SEARCH_ROUTE) { SearchRoute( onBackClick = onBackClick, onInterestsClick = onInterestsClick, diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml index 2a824653e..e11576747 100644 --- a/feature/search/src/main/res/values/strings.xml +++ b/feature/search/src/main/res/values/strings.xml @@ -15,15 +15,15 @@ limitations under the License. --> - Search - Clear search text - Sorry, there is no content found for your search \"%1$s\" - Sorry, we are still processing the search index. Please come back later. - Try another search or explorer - Interests - to browse topics - Topics - Updates - Recent searches - Clear searches + Search + Clear search text + Sorry, there is no content found for your search \"%1$s\" + Sorry, we are still processing the search index. Please come back later + Try another search or explorer + Interests + to browse topics + Topics + Updates + Recent searches + Clear searches diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index ef367d612..4f5d649b7 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -15,9 +15,9 @@ */ plugins { - id("nowinandroid.android.feature") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) } android { diff --git a/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt b/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt index febc606b0..790b5964d 100644 --- a/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt +++ b/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt @@ -48,7 +48,7 @@ class SettingsDialogTest { } composeTestRule - .onNodeWithText(getString(R.string.loading)) + .onNodeWithText(getString(R.string.feature_settings_loading)) .assertExists() } @@ -71,17 +71,17 @@ class SettingsDialogTest { } // Check that all the possible settings are displayed. - composeTestRule.onNodeWithText(getString(R.string.brand_default)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.brand_android)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_default)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertExists() composeTestRule.onNodeWithText( - getString(R.string.dark_mode_config_system_default), + getString(R.string.feature_settings_dark_mode_config_system_default), ).assertExists() - composeTestRule.onNodeWithText(getString(R.string.dark_mode_config_light)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.dark_mode_config_dark)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_light)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertExists() // Check that the correct settings are selected. - composeTestRule.onNodeWithText(getString(R.string.brand_android)).assertIsSelected() - composeTestRule.onNodeWithText(getString(R.string.dark_mode_config_dark)).assertIsSelected() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertIsSelected() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertIsSelected() } @Test @@ -103,12 +103,12 @@ class SettingsDialogTest { ) } - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertExists() // Check that the correct default dynamic color setting is selected. - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertIsSelected() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertIsSelected() } @Test @@ -129,10 +129,10 @@ class SettingsDialogTest { ) } - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference)) + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)) .assertDoesNotExist() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertDoesNotExist() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertDoesNotExist() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist() } @Test @@ -153,10 +153,10 @@ class SettingsDialogTest { ) } - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_preference)) + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)) .assertDoesNotExist() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_yes)).assertDoesNotExist() - composeTestRule.onNodeWithText(getString(R.string.dynamic_color_no)).assertDoesNotExist() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist() } @Test @@ -177,9 +177,9 @@ class SettingsDialogTest { ) } - composeTestRule.onNodeWithText(getString(R.string.privacy_policy)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.licenses)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.brand_guidelines)).assertExists() - composeTestRule.onNodeWithText(getString(R.string.feedback)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_privacy_policy)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_licenses)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_guidelines)).assertExists() + composeTestRule.onNodeWithText(getString(R.string.feature_settings_feedback)).assertExists() } } diff --git a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt index 01ec30e74..032515c53 100644 --- a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("ktlint:standard:max-line-length") + package com.google.samples.apps.nowinandroid.feature.settings import android.content.Intent @@ -108,7 +110,7 @@ fun SettingsDialog( onDismissRequest = { onDismiss() }, title = { Text( - text = stringResource(string.settings_title), + text = stringResource(string.feature_settings_title), style = MaterialTheme.typography.titleLarge, ) }, @@ -118,7 +120,7 @@ fun SettingsDialog( when (settingsUiState) { Loading -> { Text( - text = stringResource(string.loading), + text = stringResource(string.feature_settings_loading), modifier = Modifier.padding(vertical = 16.dp), ) } @@ -140,7 +142,7 @@ fun SettingsDialog( }, confirmButton = { Text( - text = stringResource(string.dismiss_dialog_button_text), + text = stringResource(string.feature_settings_dismiss_dialog_button_text), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier @@ -160,50 +162,50 @@ private fun ColumnScope.SettingsPanel( onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit, onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit, ) { - SettingsDialogSectionTitle(text = stringResource(string.theme)) + SettingsDialogSectionTitle(text = stringResource(string.feature_settings_theme)) Column(Modifier.selectableGroup()) { SettingsDialogThemeChooserRow( - text = stringResource(string.brand_default), + text = stringResource(string.feature_settings_brand_default), selected = settings.brand == DEFAULT, onClick = { onChangeThemeBrand(DEFAULT) }, ) SettingsDialogThemeChooserRow( - text = stringResource(string.brand_android), + text = stringResource(string.feature_settings_brand_android), selected = settings.brand == ANDROID, onClick = { onChangeThemeBrand(ANDROID) }, ) } AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) { Column { - SettingsDialogSectionTitle(text = stringResource(string.dynamic_color_preference)) + SettingsDialogSectionTitle(text = stringResource(string.feature_settings_dynamic_color_preference)) Column(Modifier.selectableGroup()) { SettingsDialogThemeChooserRow( - text = stringResource(string.dynamic_color_yes), + text = stringResource(string.feature_settings_dynamic_color_yes), selected = settings.useDynamicColor, onClick = { onChangeDynamicColorPreference(true) }, ) SettingsDialogThemeChooserRow( - text = stringResource(string.dynamic_color_no), + text = stringResource(string.feature_settings_dynamic_color_no), selected = !settings.useDynamicColor, onClick = { onChangeDynamicColorPreference(false) }, ) } } } - SettingsDialogSectionTitle(text = stringResource(string.dark_mode_preference)) + SettingsDialogSectionTitle(text = stringResource(string.feature_settings_dark_mode_preference)) Column(Modifier.selectableGroup()) { SettingsDialogThemeChooserRow( - text = stringResource(string.dark_mode_config_system_default), + text = stringResource(string.feature_settings_dark_mode_config_system_default), selected = settings.darkThemeConfig == FOLLOW_SYSTEM, onClick = { onChangeDarkThemeConfig(FOLLOW_SYSTEM) }, ) SettingsDialogThemeChooserRow( - text = stringResource(string.dark_mode_config_light), + text = stringResource(string.feature_settings_dark_mode_config_light), selected = settings.darkThemeConfig == LIGHT, onClick = { onChangeDarkThemeConfig(LIGHT) }, ) SettingsDialogThemeChooserRow( - text = stringResource(string.dark_mode_config_dark), + text = stringResource(string.feature_settings_dark_mode_config_dark), selected = settings.darkThemeConfig == DARK, onClick = { onChangeDarkThemeConfig(DARK) }, ) @@ -259,7 +261,7 @@ private fun LinksPanel() { NiaTextButton( onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, ) { - Text(text = stringResource(string.privacy_policy)) + Text(text = stringResource(string.feature_settings_privacy_policy)) } val context = LocalContext.current NiaTextButton( @@ -267,17 +269,17 @@ private fun LinksPanel() { context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) }, ) { - Text(text = stringResource(string.licenses)) + Text(text = stringResource(string.feature_settings_licenses)) } NiaTextButton( onClick = { uriHandler.openUri(BRAND_GUIDELINES_URL) }, ) { - Text(text = stringResource(string.brand_guidelines)) + Text(text = stringResource(string.feature_settings_brand_guidelines)) } NiaTextButton( onClick = { uriHandler.openUri(FEEDBACK_URL) }, ) { - Text(text = stringResource(string.feedback)) + Text(text = stringResource(string.feature_settings_feedback)) } } } @@ -316,7 +318,6 @@ private fun PreviewSettingsDialogLoading() { } } -/* ktlint-disable max-line-length */ private const val PRIVACY_POLICY_URL = "https://policies.google.com/privacy" private const val BRAND_GUIDELINES_URL = "https://developer.android.com/distribute/marketing-tools/brand-guidelines" private const val FEEDBACK_URL = "https://goo.gle/nia-app-feedback" diff --git a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt index 33bf58a2c..a49c0d512 100644 --- a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt @@ -87,6 +87,6 @@ data class UserEditableSettings( ) sealed interface SettingsUiState { - object Loading : SettingsUiState + data object Loading : SettingsUiState data class Success(val settings: UserEditableSettings) : SettingsUiState } diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index ad56f6b08..887539bd5 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -15,23 +15,23 @@ limitations under the License. --> - Settings - Search - Settings - Loading… - Privacy policy - Licenses - Brand Guidelines - Feedback - Theme - Default - Android - Dark mode preference - System default - Light - Dark - Use Dynamic Color - Yes - No - OK + Settings + Search + Settings + Loading… + Privacy policy + Licenses + Brand Guidelines + Feedback + Theme + Default + Android + Dark mode preference + System default + Light + Dark + Use Dynamic Color + Yes + No + OK diff --git a/feature/topic/build.gradle.kts b/feature/topic/build.gradle.kts index ecb0630ce..cc0ecc868 100644 --- a/feature/topic/build.gradle.kts +++ b/feature/topic/build.gradle.kts @@ -15,9 +15,9 @@ */ plugins { - id("nowinandroid.android.feature") - id("nowinandroid.android.library.compose") - id("nowinandroid.android.library.jacoco") + alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) } android { diff --git a/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt index 94f86a8e4..b64e397ea 100644 --- a/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt @@ -45,7 +45,7 @@ class TopicScreenTest { @Before fun setup() { composeTestRule.activity.apply { - topicLoading = getString(R.string.topic_loading) + topicLoading = getString(R.string.feature_topic_loading) } } diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index 3dbbe7da8..3f3862c2a 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -119,7 +119,7 @@ internal fun TopicScreen( TopicUiState.Loading -> item { NiaLoadingWheel( modifier = modifier, - contentDesc = stringResource(id = string.topic_loading), + contentDesc = stringResource(id = string.feature_topic_loading), ) } @@ -284,7 +284,7 @@ private fun TopicToolbar( Icon( imageVector = NiaIcons.ArrowBack, contentDescription = stringResource( - id = com.google.samples.apps.nowinandroid.core.ui.R.string.back, + id = com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_back, ), ) } diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index 1c39f29da..6adfe0a67 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -177,12 +177,12 @@ private fun newsUiState( sealed interface TopicUiState { data class Success(val followableTopic: FollowableTopic) : TopicUiState - object Error : TopicUiState - object Loading : TopicUiState + data object Error : TopicUiState + data object Loading : TopicUiState } sealed interface NewsUiState { data class Success(val news: List) : NewsUiState - object Error : NewsUiState - object Loading : NewsUiState + data object Error : NewsUiState + data object Loading : NewsUiState } diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt index c29b57d47..8052f766e 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt +++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt @@ -31,11 +31,11 @@ import kotlin.text.Charsets.UTF_8 private val URL_CHARACTER_ENCODING = UTF_8.name() @VisibleForTesting -internal const val topicIdArg = "topicId" +internal const val TOPIC_ID_ARG = "topicId" internal class TopicArgs(val topicId: String) { constructor(savedStateHandle: SavedStateHandle) : - this(URLDecoder.decode(checkNotNull(savedStateHandle[topicIdArg]), URL_CHARACTER_ENCODING)) + this(URLDecoder.decode(checkNotNull(savedStateHandle[TOPIC_ID_ARG]), URL_CHARACTER_ENCODING)) } fun NavController.navigateToTopic(topicId: String) { @@ -50,9 +50,9 @@ fun NavGraphBuilder.topicScreen( onTopicClick: (String) -> Unit, ) { composable( - route = "topic_route/{$topicIdArg}", + route = "topic_route/{$TOPIC_ID_ARG}", arguments = listOf( - navArgument(topicIdArg) { type = NavType.StringType }, + navArgument(TOPIC_ID_ARG) { type = NavType.StringType }, ), ) { TopicRoute(onBackClick = onBackClick, onTopicClick = onTopicClick) diff --git a/feature/topic/src/main/res/values/strings.xml b/feature/topic/src/main/res/values/strings.xml index 284f2f7b2..5fefc3f42 100644 --- a/feature/topic/src/main/res/values/strings.xml +++ b/feature/topic/src/main/res/values/strings.xml @@ -15,5 +15,5 @@ limitations under the License. --> - Loading topic + Loading topic diff --git a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt index 8c6253d60..a9c9d96dc 100644 --- a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt @@ -25,7 +25,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepo import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule -import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicIdArg +import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ID_ARG import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first @@ -60,7 +60,7 @@ class TopicViewModelTest { @Before fun setup() { viewModel = TopicViewModel( - savedStateHandle = SavedStateHandle(mapOf(topicIdArg to testInputTopics[0].topic.id)), + savedStateHandle = SavedStateHandle(mapOf(TOPIC_ID_ARG to testInputTopics[0].topic.id)), userDataRepository = userDataRepository, topicsRepository = topicsRepository, userNewsResourceRepository = userNewsResourceRepository, diff --git a/gradle.properties b/gradle.properties index b57dc01ed..c0acfeb02 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,6 +23,10 @@ org.gradle.caching=true # Enable configuration caching between builds. org.gradle.configuration-cache=true +# This option is set because of https://github.com/google/play-services-plugins/issues/246 +# to generate the Configuration Cache regardless of incompatible tasks. +# See https://github.com/android/nowinandroid/issues/1022 before using it. +org.gradle.configuration-cache.problems=warn # AndroidX package structure to make it clearer which packages are bundled with the # Android operating system, and which are packaged with your app"s APK @@ -31,9 +35,6 @@ android.useAndroidX=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -# Non-transitive R classes is recommended and is faster/smaller -android.nonTransitiveRClass=true - # Disable build features that are enabled by default, # https://developer.android.com/build/releases/gradle-plugin#default-changes android.defaults.buildfeatures.resvalues=false diff --git a/gradle/init.gradle.kts b/gradle/init.gradle.kts index 57f91da08..fe79fa01e 100644 --- a/gradle/init.gradle.kts +++ b/gradle/init.gradle.kts @@ -14,10 +14,10 @@ * limitations under the License. */ -val ktlintVersion = "0.48.1" +val ktlintVersion = "1.0.1" initscript { - val spotlessVersion = "6.13.0" + val spotlessVersion = "6.23.3" repositories { mavenCentral() @@ -35,7 +35,11 @@ rootProject { kotlin { target("**/*.kt") targetExclude("**/build/**/*.kt") - ktlint(ktlintVersion).userData(mapOf("android" to "true")) + ktlint(ktlintVersion).editorConfigOverride( + mapOf( + "android" to "true", + ), + ) licenseHeaderFile(rootProject.file("spotless/copyright.kt")) } format("kts") { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 08391f2e6..02aa9280f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,59 +1,64 @@ [versions] -accompanist = "0.28.0" -androidDesugarJdkLibs = "2.0.3" -androidGradlePlugin = "8.1.1" -androidxActivity = "1.8.0-alpha06" -androidxAppCompat = "1.5.1" -androidxBrowser = "1.4.0" -androidxComposeBom = "2023.06.01" -androidxComposeCompiler = "1.5.0" -androidxComposeRuntimeTracing = "1.0.0-alpha03" -androidxCore = "1.9.0" -androidxCoreSplashscreen = "1.0.0" +accompanist = "0.32.0" +androidDesugarJdkLibs = "2.0.4" +# AGP and tools should be updated together +androidGradlePlugin = "8.2.0" +androidTools = "31.1.3" +androidxActivity = "1.8.0" +androidxAppCompat = "1.6.1" +androidxBrowser = "1.6.0" +androidxComposeBom = "2023.10.01" +androidxComposeCompiler = "1.5.7" +androidxComposeRuntimeTracing = "1.0.0-beta01" +androidxCore = "1.12.0" +androidxCoreSplashscreen = "1.0.1" androidxDataStore = "1.0.0" -androidxEspresso = "3.5.0" +androidxEspresso = "3.5.1" androidxHiltNavigationCompose = "1.0.0" -androidxLifecycle = "2.6.1" -androidxMacroBenchmark = "1.2.0-alpha16" -androidxMetrics = "1.0.0-alpha03" -androidxNavigation = "2.5.3" +androidxJunit = "1.1.5" +androidxLifecycle = "2.6.2" +androidxMacroBenchmark = "1.2.2" +androidxMetrics = "1.0.0-alpha04" +androidxNavigation = "2.7.4" androidxProfileinstaller = "1.3.1" androidxStartup = "1.1.1" androidxTestCore = "1.5.0" -androidxTestExt = "1.1.4" +androidxTestExt = "1.1.5" androidxTestRules = "1.5.0" -androidxTestRunner = "1.5.1" +androidxTestRunner = "1.5.2" androidxTracing = "1.1.0" androidxUiAutomator = "2.2.0" -androidxWindowManager = "1.0.0" -androidxWork = "2.9.0-alpha01" -coil = "2.2.2" -firebaseBom = "31.2.0" -firebaseCrashlyticsPlugin = "2.9.2" +androidxWindowManager = "1.2.0" +androidxWork = "2.9.0" +coil = "2.5.0" +dependencyGuard = "0.4.3" +firebaseBom = "32.4.0" +firebaseCrashlyticsPlugin = "2.9.9" firebasePerfPlugin = "1.4.2" -gmsPlugin = "4.3.14" +gmsPlugin = "4.4.0" googleOss = "17.0.1" googleOssPlugin = "0.10.6" -hilt = "2.47" -hiltExt = "1.0.0" +hilt = "2.50" +hiltExt = "1.1.0" jacoco = "0.8.7" junit4 = "4.13.2" -kotlin = "1.9.0" -kotlinxCoroutines = "1.6.4" -kotlinxDatetime = "0.4.0" -kotlinxSerializationJson = "1.5.1" -ksp = "1.9.0-1.0.13" -lint = "31.0.2" -okhttp = "4.10.0" -protobuf = "3.24.0" +kotlin = "1.9.21" +kotlinxCoroutines = "1.7.3" +kotlinxDatetime = "0.5.0" +kotlinxSerializationJson = "1.6.0" +ksp = "1.9.21-1.0.16" +lint = "31.2.0" +okhttp = "4.12.0" +protobuf = "3.24.4" protobufPlugin = "0.9.4" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" -robolectric = "4.10.3" -roborazzi = "1.5.0-alpha-2" -room = "2.5.2" +robolectric = "4.11.1" +roborazzi = "1.6.0" +room = "2.6.1" secrets = "2.0.1" -turbine = "0.12.1" +truth = "1.1.5" +turbine = "1.0.0" [libraries] accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } @@ -64,6 +69,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-benchmark-macro = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "androidxMacroBenchmark" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "androidxBrowser" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" } +androidx-compose-compiler = { group = "androidx.compose.compiler", name = "compiler", version.ref = "androidxComposeCompiler" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } @@ -122,6 +128,8 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref = "lint" } +lint-checks = { group = "com.android.tools.lint", name = "lint-checks", version.ref = "lint" } +lint-tests = { group = "com.android.tools.lint", name = "lint-tests", version.ref = "lint" } okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } @@ -133,20 +141,26 @@ roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", versi room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +android-tools-common = { group = "com.android.tools", name = "common", version.ref = "androidTools" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } firebase-crashlytics-gradlePlugin = { group = "com.google.firebase", name = "firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin" } firebase-performance-gradlePlugin = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } -work-testing = { group = "androidx.work", name = "work-testing", version = "2.8.1" } +room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" } +work-testing = { group = "androidx.work", name = "work-testing", version = "2.9.0" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } +baselineprofile = { id = "androidx.baselineprofile", version.ref = "androidxMacroBenchmark"} +dependencyGuard = { id = "com.dropbox.dependency-guard", version.ref = "dependencyGuard" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin" } gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin" } @@ -156,4 +170,21 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } +room = { id = "androidx.room", version.ref = "room" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } + +# Plugins defined by this project +nowinandroid-android-application = { id = "nowinandroid.android.application", version = "unspecified" } +nowinandroid-android-application-compose = { id = "nowinandroid.android.application.compose", version = "unspecified" } +nowinandroid-android-application-firebase = { id = "nowinandroid.android.application.firebase", version = "unspecified" } +nowinandroid-android-application-flavors = { id = "nowinandroid.android.application.flavors", version = "unspecified" } +nowinandroid-android-application-jacoco = { id = "nowinandroid.android.application.jacoco", version = "unspecified" } +nowinandroid-android-feature = { id = "nowinandroid.android.feature", version = "unspecified" } +nowinandroid-android-hilt = { id = "nowinandroid.android.hilt", version = "unspecified" } +nowinandroid-android-library = { id = "nowinandroid.android.library", version = "unspecified" } +nowinandroid-android-library-compose = { id = "nowinandroid.android.library.compose", version = "unspecified" } +nowinandroid-android-library-jacoco = { id = "nowinandroid.android.library.jacoco", version = "unspecified" } +nowinandroid-android-lint = { id = "nowinandroid.android.lint", version = "unspecified" } +nowinandroid-android-room = { id = "nowinandroid.android.room", version = "unspecified" } +nowinandroid-android-test = { id = "nowinandroid.android.test", version = "unspecified" } +nowinandroid-jvm-library = { id = "nowinandroid.jvm.library", version = "unspecified" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 033e24c4c..d64cd4917 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 62f495dfe..1af9e0930 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index fcb6fca14..1aa94a426 100755 --- a/gradlew +++ b/gradlew @@ -83,7 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -144,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -152,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -201,11 +202,11 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index 35b6ec1e8..acb540c3b 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -19,7 +19,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` kotlin("jvm") - id("nowinandroid.android.lint") + alias(libs.plugins.nowinandroid.android.lint) } java { @@ -38,4 +38,7 @@ tasks.withType().configureEach { dependencies { compileOnly(libs.kotlin.stdlib) compileOnly(libs.lint.api) + testImplementation(libs.lint.checks) + testImplementation(libs.lint.tests) + testImplementation(kotlin("test")) } diff --git a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt similarity index 76% rename from lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt rename to lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt index bb7e971e3..b806312fd 100644 --- a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt +++ b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt @@ -14,18 +14,20 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.lint.designsystem +package com.google.samples.apps.nowinandroid.lint import com.android.tools.lint.client.api.IssueRegistry import com.android.tools.lint.client.api.Vendor import com.android.tools.lint.detector.api.CURRENT_API +import com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector -/** - * An issue registry that checks for incorrect usages of Compose Material APIs over equivalents in - * the Now in Android design system module. - */ -class DesignSystemIssueRegistry : IssueRegistry() { - override val issues = listOf(DesignSystemDetector.ISSUE) +class NiaIssueRegistry : IssueRegistry() { + + override val issues = listOf( + DesignSystemDetector.ISSUE, + TestMethodNameDetector.FORMAT, + TestMethodNameDetector.PREFIX, + ) override val api: Int = CURRENT_API diff --git a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetector.kt b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetector.kt new file mode 100644 index 000000000..532994d99 --- /dev/null +++ b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetector.kt @@ -0,0 +1,126 @@ +/* + * 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.lint + +import com.android.tools.lint.detector.api.AnnotationInfo +import com.android.tools.lint.detector.api.AnnotationUsageInfo +import com.android.tools.lint.detector.api.Category.Companion.TESTING +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.LintFix +import com.android.tools.lint.detector.api.Scope.JAVA_FILE +import com.android.tools.lint.detector.api.Scope.TEST_SOURCES +import com.android.tools.lint.detector.api.Severity.WARNING +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.TextFormat.RAW +import com.intellij.psi.PsiMethod +import org.jetbrains.uast.UElement +import java.util.EnumSet +import kotlin.io.path.Path + +/** + * A detector that checks for common patterns in naming the test methods: + * - [detectPrefix] removes unnecessary "test" prefix in all unit test. + * - [detectFormat] Checks the `given_when_then` format of Android instrumented tests (backticks are not supported). + */ +class TestMethodNameDetector : Detector(), SourceCodeScanner { + + override fun applicableAnnotations() = listOf("org.junit.Test") + + override fun visitAnnotationUsage( + context: JavaContext, + element: UElement, + annotationInfo: AnnotationInfo, + usageInfo: AnnotationUsageInfo, + ) { + val method = usageInfo.referenced as? PsiMethod ?: return + + method.detectPrefix(context, usageInfo) + method.detectFormat(context, usageInfo) + } + + private fun JavaContext.isAndroidTest() = Path("androidTest") in file.toPath() + + private fun PsiMethod.detectPrefix( + context: JavaContext, + usageInfo: AnnotationUsageInfo, + ) { + if (!name.startsWith("test")) return + context.report( + issue = PREFIX, + scope = usageInfo.usage, + location = context.getNameLocation(this), + message = PREFIX.getBriefDescription(RAW), + quickfixData = LintFix.create() + .name("Remove prefix") + .replace().pattern("""test[\s_]*""") + .with("") + .autoFix() + .build(), + ) + } + + private fun PsiMethod.detectFormat( + context: JavaContext, + usageInfo: AnnotationUsageInfo, + ) { + if (!context.isAndroidTest()) return + if ("""[^\W_]+(_[^\W_]+){1,2}""".toRegex().matches(name)) return + context.report( + issue = FORMAT, + scope = usageInfo.usage, + location = context.getNameLocation(this), + message = FORMAT.getBriefDescription(RAW), + ) + } + + companion object { + + private fun issue( + id: String, + briefDescription: String, + explanation: String, + ): Issue = Issue.create( + id = id, + briefDescription = briefDescription, + explanation = explanation, + category = TESTING, + priority = 5, + severity = WARNING, + implementation = Implementation( + TestMethodNameDetector::class.java, + EnumSet.of(JAVA_FILE, TEST_SOURCES), + ), + ) + + @JvmField + val PREFIX: Issue = issue( + id = "TestMethodPrefix", + briefDescription = "Test method starts with `test`", + explanation = "Test method should not start with `test`.", + ) + + @JvmField + val FORMAT: Issue = issue( + id = "TestMethodFormat", + briefDescription = "Test method does not follow the `given_when_then` or `when_then` format", + explanation = "Test method should follow the `given_when_then` or `when_then` format.", + ) + } +} diff --git a/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry b/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry index 4b8002da2..e673c27ff 100644 --- a/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry +++ b/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry @@ -14,4 +14,4 @@ # limitations under the License. # -com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemIssueRegistry +com.google.samples.apps.nowinandroid.lint.NiaIssueRegistry diff --git a/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetectorTest.kt b/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetectorTest.kt new file mode 100644 index 000000000..8da173285 --- /dev/null +++ b/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetectorTest.kt @@ -0,0 +1,123 @@ +/* + * 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.lint + +import com.android.tools.lint.checks.infrastructure.TestFile +import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import com.google.samples.apps.nowinandroid.lint.TestMethodNameDetector.Companion.FORMAT +import com.google.samples.apps.nowinandroid.lint.TestMethodNameDetector.Companion.PREFIX +import org.junit.Test + +class TestMethodNameDetectorTest { + + @Test + fun `detect prefix`() { + lint().issues(PREFIX) + .files( + JUNIT_TEST_STUB, + kotlin( + """ + import org.junit.Test + class Test { + @Test + fun foo() = Unit + @Test + fun test_foo() = Unit + @Test + fun `test foo`() = Unit + } + """, + ).indented(), + ) + .run() + .expect( + """ + src/Test.kt:6: Warning: Test method starts with test [TestMethodPrefix] + fun test_foo() = Unit + ~~~~~~~~ + src/Test.kt:8: Warning: Test method starts with test [TestMethodPrefix] + fun `test foo`() = Unit + ~~~~~~~~~~ + 0 errors, 2 warnings + """.trimIndent(), + ) + .expectFixDiffs( + """ + Autofix for src/Test.kt line 6: Remove prefix: + @@ -6 +6 + - fun test_foo() = Unit + + fun foo() = Unit + Autofix for src/Test.kt line 8: Remove prefix: + @@ -8 +8 + - fun `test foo`() = Unit + + fun `foo`() = Unit + """.trimIndent(), + ) + } + + @Test + fun `detect format`() { + lint().issues(FORMAT) + .files( + JUNIT_TEST_STUB, + kotlin( + "src/androidTest/com/example/Test.kt", + """ + import org.junit.Test + class Test { + @Test + fun when_then() = Unit + @Test + fun given_when_then() = Unit + + @Test + fun foo() = Unit + @Test + fun foo_bar_baz_qux() = Unit + @Test + fun `foo bar baz`() = Unit + } + """, + ).indented(), + ) + .run() + .expect( + """ + src/androidTest/com/example/Test.kt:9: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat] + fun foo() = Unit + ~~~ + src/androidTest/com/example/Test.kt:11: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat] + fun foo_bar_baz_qux() = Unit + ~~~~~~~~~~~~~~~ + src/androidTest/com/example/Test.kt:13: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat] + fun `foo bar baz`() = Unit + ~~~~~~~~~~~~~ + 0 errors, 3 warnings + """.trimIndent(), + ) + } + + private companion object { + private val JUNIT_TEST_STUB: TestFile = kotlin( + """ + package org.junit + annotation class Test + """, + ).indented() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index d0c477b3d..fa043c955 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,8 @@ dependencyResolutionManagement { } } rootProject.name = "nowinandroid" + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":app") include(":app-nia-catalog") include(":benchmarks") @@ -39,6 +41,7 @@ include(":core:data") include(":core:data-test") include(":core:database") include(":core:datastore") +include(":core:datastore-proto") include(":core:datastore-test") include(":core:designsystem") include(":core:domain") diff --git a/sync/sync-test/build.gradle.kts b/sync/sync-test/build.gradle.kts index 99909af6b..02e573ae5 100644 --- a/sync/sync-test/build.gradle.kts +++ b/sync/sync-test/build.gradle.kts @@ -14,8 +14,8 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.hilt) } android { @@ -23,7 +23,7 @@ android { } dependencies { - api(project(":sync:work")) - implementation(project(":core:data")) - implementation(project(":core:testing")) + api(projects.sync.work) + implementation(projects.core.data) + implementation(projects.core.testing) } diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index 79902e486..867aef17c 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -14,9 +14,9 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.library.jacoco") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.jacoco) + alias(libs.plugins.nowinandroid.android.hilt) } android { @@ -27,22 +27,23 @@ android { } dependencies { - implementation(project(":core:analytics")) - implementation(project(":core:common")) - implementation(project(":core:data")) - implementation(project(":core:datastore")) - implementation(project(":core:model")) + implementation(projects.core.analytics) + implementation(projects.core.common) + implementation(projects.core.data) + implementation(projects.core.datastore) + implementation(projects.core.model) implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.work.ktx) - implementation(libs.firebase.cloud.messaging) implementation(libs.hilt.ext.work) implementation(libs.kotlinx.coroutines.android) - kapt(libs.hilt.ext.compiler) + prodImplementation(libs.firebase.cloud.messaging) - testImplementation(project(":core:testing")) + ksp(libs.hilt.ext.compiler) - androidTestImplementation(project(":core:testing")) + testImplementation(projects.core.testing) + + androidTestImplementation(projects.core.testing) androidTestImplementation(libs.androidx.work.testing) } diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt index 00f61f17d..0a631534b 100644 --- a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt +++ b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt @@ -28,7 +28,7 @@ object Sync { WorkManager.getInstance(context).apply { // Run sync on app startup and ensure only one sync worker runs at any time enqueueUniqueWork( - SyncWorkName, + SYNC_WORK_NAME, ExistingWorkPolicy.KEEP, SyncWorker.startUpSyncWork(), ) @@ -37,4 +37,4 @@ object Sync { } // This name should not be changed otherwise the app may have concurrent sync requests running -internal const val SyncWorkName = "SyncWorkName" +internal const val SYNC_WORK_NAME = "SyncWorkName" diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt index 5abf2eee5..843510aaf 100644 --- a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt +++ b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt @@ -54,10 +54,10 @@ private fun Context.syncWorkNotification(): Notification { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( SYNC_NOTIFICATION_CHANNEL_ID, - getString(R.string.sync_notification_channel_name), + getString(R.string.sync_work_notification_channel_name), NotificationManager.IMPORTANCE_DEFAULT, ).apply { - description = getString(R.string.sync_notification_channel_description) + description = getString(R.string.sync_work_notification_channel_description) } // Register the channel with the system val notificationManager: NotificationManager? = @@ -71,9 +71,9 @@ private fun Context.syncWorkNotification(): Notification { SYNC_NOTIFICATION_CHANNEL_ID, ) .setSmallIcon( - com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, + com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification, ) - .setContentTitle(getString(R.string.sync_notification_title)) + .setContentTitle(getString(R.string.sync_work_notification_title)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .build() } diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt index 9131e4888..1d251588e 100644 --- a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt +++ b/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt @@ -22,7 +22,7 @@ import androidx.work.WorkInfo import androidx.work.WorkInfo.State import androidx.work.WorkManager import com.google.samples.apps.nowinandroid.core.data.util.SyncManager -import com.google.samples.apps.nowinandroid.sync.initializers.SyncWorkName +import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_WORK_NAME import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow @@ -37,7 +37,7 @@ class WorkManagerSyncManager @Inject constructor( @ApplicationContext private val context: Context, ) : SyncManager { override val isSyncing: Flow = - WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(SyncWorkName) + WorkManager.getInstance(context).getWorkInfosForUniqueWorkFlow(SYNC_WORK_NAME) .map(List::anyRunning) .conflate() @@ -45,7 +45,7 @@ class WorkManagerSyncManager @Inject constructor( val workManager = WorkManager.getInstance(context) // Run sync on app startup and ensure only one sync worker runs at any time workManager.enqueueUniqueWork( - SyncWorkName, + SYNC_WORK_NAME, ExistingWorkPolicy.KEEP, SyncWorker.startUpSyncWork(), ) diff --git a/sync/work/src/main/res/values/strings.xml b/sync/work/src/main/res/values/strings.xml index e3fd73ff8..4d77f6a7b 100644 --- a/sync/work/src/main/res/values/strings.xml +++ b/sync/work/src/main/res/values/strings.xml @@ -15,8 +15,8 @@ limitations under the License. --> - Now in Android - Sync - Background tasks for Now in Android + Now in Android + Sync + Background tasks for Now in Android diff --git a/sync/work/src/main/AndroidManifest.xml b/sync/work/src/prod/AndroidManifest.xml similarity index 100% rename from sync/work/src/main/AndroidManifest.xml rename to sync/work/src/prod/AndroidManifest.xml diff --git a/sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt b/sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt similarity index 100% rename from sync/work/src/main/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt rename to sync/work/src/prod/kotlin/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt diff --git a/ui-test-hilt-manifest/build.gradle.kts b/ui-test-hilt-manifest/build.gradle.kts index b55036591..f41482814 100644 --- a/ui-test-hilt-manifest/build.gradle.kts +++ b/ui-test-hilt-manifest/build.gradle.kts @@ -14,8 +14,8 @@ * limitations under the License. */ plugins { - id("nowinandroid.android.library") - id("nowinandroid.android.hilt") + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.hilt) } android {