Merge remote-tracking branch 'origin/main' into splashscreen-user-theme-preference

pull/1718/head
Andrea Severi 3 months ago
commit 5f662b7709
No known key found for this signature in database
GPG Key ID: 8581A4922655BF07

@ -17,6 +17,8 @@
org.gradle.daemon=false
org.gradle.parallel=true
org.gradle.workers.max=2
org.gradle.configuration-cache=true
org.gradle.configuration-cache.parallel=true
kotlin.incremental=false

@ -5,5 +5,10 @@
],
"baseBranches": [
"main"
],
"gitIgnoredAuthors": [
"renovate[bot]@users.noreply.github.com",
"github-actions[bot]@users.noreply.github.com",
"41898282+github-actions[bot]@users.noreply.github.com"
]
}
}

@ -1,6 +1,7 @@
name: Build
on:
workflow_dispatch:
push:
branches:
- main
@ -26,33 +27,25 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls /dev/kvm
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Accept licenses
run: yes | sdkmanager --licenses || true
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-agree: "yes"
- name: Check build-logic
run: ./gradlew check -p build-logic
run: ./gradlew :build-logic:convention:check
- name: Check spotless
run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache
@ -84,6 +77,29 @@ jobs:
disable_globbing: true
commit_message: "🤖 Updates baselines for Dependency Guard"
- name: Update Graphs
run: ./gradlew graphUpdate
continue-on-error: true
- name: Check Graphs
id: graphs_verify
run: git add -- "**/README.md" && git diff --cached --quiet --exit-code -- "**/README.md"
- name: Prevent updating graphs if this is a fork
id: checkfork_graphs
continue-on-error: false
if: steps.graphs_verify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::Check Graphs failed, please update graphs with: ./gradlew graphUpdate" && exit 1
- name: Push new graphs if available
if: steps.graphs_verify.outcome == 'failure' && github.event_name == 'pull_request'
uses: stefanzweifel/git-auto-commit-action@v5
with:
file_pattern: '**/README.md'
disable_globbing: true
commit_message: "🤖 Updates graphs"
- name: Run all local screenshot tests (Roborazzi)
id: screenshotsverify
continue-on-error: true
@ -118,7 +134,7 @@ jobs:
run: ./gradlew testDemoDebug :lint:test
- name: Build all build type and flavor permutations
run: ./gradlew :app:assemble
run: ./gradlew :app:assemble -PminifyWithR8=false
- name: Upload build outputs (APKs)
uses: actions/upload-artifact@v4
@ -150,11 +166,26 @@ jobs:
name: lint-reports
path: '**/build/reports/lint-results-*.html'
- name: Upload lint reports (SARIF)
if: ${{ !cancelled() && hashFiles('**/*.sarif') != '' }}
- name: Upload lint reports (SARIF) for app module
if: ${{ !cancelled() && hashFiles('app/**/*.sarif') != '' }}
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: './'
sarif_file: './app/'
category: app
- name: Upload lint reports (SARIF) for app-nia-catalog module
if: ${{ !cancelled() && hashFiles('app-nia-catalog/**/*.sarif') != '' }}
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: './app-nia-catalog/'
category: app-nia-catalog
- name: Upload lint reports (SARIF) for lint module
if: ${{ !cancelled() && hashFiles('lint/**/*.sarif') != '' }}
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: './lint/'
category: lint
- name: Check badging
run: ./gradlew :app:checkProdReleaseBadging
@ -191,14 +222,19 @@ jobs:
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- name: Set up JDK 17
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
java-version: 21
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-agree: "yes"
- name: Build projects and run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2

@ -1,12 +1,14 @@
name: NightlyBaselineProfiles
on:
workflow_dispatch:
schedule:
- cron: '42 4 * * *'
jobs:
baseline_profiles:
name: "Generate Baseline Profiles"
if: github.repository == 'android/nowinandroid'
runs-on: ubuntu-latest
permissions:
@ -36,6 +38,11 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-agree: "yes"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
@ -44,7 +51,7 @@ jobs:
run: yes | sdkmanager --licenses || true
- name: Check build-logic
run: ./gradlew check -p build-logic
run: ./gradlew :build-logic:convention:check
- name: Setup GMD
run: ./gradlew :benchmarks:pixel6Api33Setup
@ -52,9 +59,8 @@ jobs:
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
# This generates both baseline and startup profile and adds them into the generated folder
- name: Generate Baseline Profile
run: ./gradlew :app:generateReleaseBaselineProfile
-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=baselineprofile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
--stacktrace
- name: Build all build type and flavor permutations including baseline profiles
run: ./gradlew :app:assemble
-Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=baselineprofile
-Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect"
-Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true

@ -1,12 +1,14 @@
name: GitHub Release with APKs
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
build:
if: github.repository == 'android/nowinandroid'
runs-on: ubuntu-latest
timeout-minutes: 120
@ -33,6 +35,11 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
build-scan-terms-of-use-agree: "yes"
- name: Setup Android SDK
uses: android-actions/setup-android@v3
@ -72,4 +79,4 @@ jobs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/demo/release/app-demo-release.apk
asset_name: app-demo-release.apk
asset_content_type: application/vnd.android.package-archive
asset_content_type: application/vnd.android.package-archive

@ -0,0 +1 @@
AGENTS.md

@ -0,0 +1,58 @@
# Now in Android Project
Now in Android is a native Android mobile application written in Kotlin. It provides regular news
about Android development. Users can choose to follow topics, be notified when new content is
available, and bookmark items.
## Architecture
This project is a modern Android application that follows the official architecture guidance from Google. It is a reactive, single-activity app that uses the following:
- **UI:** Built entirely with Jetpack Compose, including Material 3 components and adaptive layouts for different screen sizes.
- **State Management:** Unidirectional Data Flow (UDF) is implemented using Kotlin Coroutines and `Flow`s. `ViewModel`s act as state holders, exposing UI state as streams of data.
- **Dependency Injection:** Hilt is used for dependency injection throughout the app, simplifying the management of dependencies and improving testability.
- **Navigation:** Navigation is handled by Jetpack Navigation 2 for Compose, allowing for a declarative and type-safe way to navigate between screens.
- **Data:** The data layer is implemented using the repository pattern.
- **Local Data:** Room and DataStore are used for local data persistence.
- **Remote Data:** Retrofit and OkHttp are used for fetching data from the network.
- **Background Processing:** WorkManager is used for deferrable background tasks.
## Modules
The main Android app lives in the `app/` folder. Feature modules live in `feature/` and core and shared modules in `core/`.
## Commands to Build & Test
The app and Android libraries have two product flavors: `demo` and `prod`, and two build types: `debug` and `release`.
- Build: `./gradlew assemble{Variant}`. Typically `assembleDemoDebug`.
- Fix linting/formatting: `./gradlew --init-script gradle/init.gradle.kts spotlessApply`
- Run local tests: `./gradlew {variant}Test`
- Run single test: `./gradlew {variant}Test --tests "com.example.myapp.MyTestClass"`
- Run local screenshot tests: `./gradlew verifyRoborazziDemoDebug`
### Instrumented tests
- Gradle-managed devices to run on device tests: `./gradlew pixel6api31aospDebugAndroidTest`. Also `pixel4api30aospatdDebugAndroidTest` and `pixelcapi30aospatdDebugAndroidTest`.
### Creating tests
#### Instrumented tests
- Tests for UI features should only use `ComposeTestRule` with a `ComponentActivity`.
- Bigger tests live in the `:app` module and they can start activities like `MainActivity`.
#### Local tests
- [kotlinx.coroutines](https://github.com/Kotlin/kotlinx.coroutines) for most assertions
- [cashapp/turbine](https://github.com/cashapp/turbine) for complex coroutine tests
- [google/truth](https://github.com/google/truth) for assertions
## Continuous integration
- The workflows are defined in `.github/workflows/*.yaml` and they contain various checks.
- Screenshot tests are generated by CI, so they shouldn't be checked into the repo from a workstation.
## Version control and code location
- The project uses git and is hosted in https://github.com/android/nowinandroid.

@ -0,0 +1 @@
* @dturner

@ -1,3 +1,58 @@
# :app-nia-catalog module
## Dependency graph
![Dependency graph](../docs/images/graphs/dep_graph_app_nia_catalog.svg)
# `:app-nia-catalog`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:analytics[analytics]:::android-library
:core:designsystem[designsystem]:::android-library
:core:model[model]:::jvm-library
:core:ui[ui]:::android-library
end
:app-nia-catalog[app-nia-catalog]:::android-application
:app-nia-catalog -.-> :core:designsystem
:app-nia-catalog -.-> :core:ui
:core:ui --> :core:analytics
:core:ui --> :core:designsystem
:core:ui --> :core:model
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -1,105 +1,121 @@
androidx.activity:activity-compose:1.9.3
androidx.activity:activity-ktx:1.9.3
androidx.activity:activity:1.9.3
androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.8.1
androidx.annotation:annotation-experimental:1.5.1
androidx.annotation:annotation-jvm:1.9.1
androidx.annotation:annotation:1.9.1
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.8.0
androidx.collection:collection-jvm:1.4.4
androidx.collection:collection-ktx:1.4.4
androidx.collection:collection:1.4.4
androidx.compose.animation:animation-android:1.7.6
androidx.compose.animation:animation-core-android:1.7.6
androidx.compose.animation:animation-core:1.7.6
androidx.compose.animation:animation:1.7.6
androidx.compose.foundation:foundation-android:1.7.6
androidx.compose.foundation:foundation-layout-android:1.7.6
androidx.compose.foundation:foundation-layout:1.7.6
androidx.compose.foundation:foundation:1.7.6
androidx.compose.material3.adaptive:adaptive-android:1.0.0
androidx.compose.material3.adaptive:adaptive:1.0.0
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.1
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.1
androidx.compose.material3:material3-android:1.3.1
androidx.compose.material3:material3:1.3.1
androidx.compose.material:material-icons-core-android:1.7.6
androidx.compose.material:material-icons-core:1.7.6
androidx.compose.material:material-icons-extended-android:1.7.6
androidx.compose.material:material-icons-extended:1.7.6
androidx.compose.material:material-ripple-android:1.7.6
androidx.compose.material:material-ripple:1.7.6
androidx.compose.runtime:runtime-android:1.7.6
androidx.compose.runtime:runtime-saveable-android:1.7.6
androidx.compose.runtime:runtime-saveable:1.7.6
androidx.compose.runtime:runtime:1.7.6
androidx.compose.ui:ui-android:1.7.6
androidx.compose.ui:ui-geometry-android:1.7.6
androidx.compose.ui:ui-geometry:1.7.6
androidx.compose.ui:ui-graphics-android:1.7.6
androidx.compose.ui:ui-graphics:1.7.6
androidx.compose.ui:ui-text-android:1.7.6
androidx.compose.ui:ui-text:1.7.6
androidx.compose.ui:ui-tooling-preview-android:1.7.6
androidx.compose.ui:ui-tooling-preview:1.7.6
androidx.compose.ui:ui-unit-android:1.7.6
androidx.compose.ui:ui-unit:1.7.6
androidx.compose.ui:ui-util-android:1.7.6
androidx.compose.ui:ui-util:1.7.6
androidx.compose.ui:ui:1.7.6
androidx.compose:compose-bom:2024.12.01
androidx.collection:collection-jvm:1.5.0
androidx.collection:collection-ktx:1.5.0
androidx.collection:collection:1.5.0
androidx.compose.animation:animation-android:1.10.0-alpha04
androidx.compose.animation:animation-core-android:1.10.0-alpha04
androidx.compose.animation:animation-core:1.10.0-alpha04
androidx.compose.animation:animation:1.10.0-alpha04
androidx.compose.foundation:foundation-android:1.10.0-alpha04
androidx.compose.foundation:foundation-layout-android:1.10.0-alpha04
androidx.compose.foundation:foundation-layout:1.10.0-alpha04
androidx.compose.foundation:foundation:1.10.0-alpha04
androidx.compose.material3.adaptive:adaptive-android:1.2.0-beta03
androidx.compose.material3.adaptive:adaptive:1.2.0-beta03
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha04
androidx.compose.material3:material3-android:1.5.0-alpha04
androidx.compose.material3:material3:1.5.0-alpha04
androidx.compose.material:material-icons-core-android:1.7.8
androidx.compose.material:material-icons-core:1.7.8
androidx.compose.material:material-icons-extended-android:1.7.8
androidx.compose.material:material-icons-extended:1.7.8
androidx.compose.material:material-ripple-android:1.10.0-alpha04
androidx.compose.material:material-ripple:1.10.0-alpha04
androidx.compose.runtime:runtime-android:1.10.0-alpha04
androidx.compose.runtime:runtime-annotation-android:1.10.0-alpha04
androidx.compose.runtime:runtime-annotation:1.10.0-alpha04
androidx.compose.runtime:runtime-retain-android:1.10.0-alpha04
androidx.compose.runtime:runtime-retain:1.10.0-alpha04
androidx.compose.runtime:runtime-saveable-android:1.10.0-alpha04
androidx.compose.runtime:runtime-saveable:1.10.0-alpha04
androidx.compose.runtime:runtime:1.10.0-alpha04
androidx.compose.ui:ui-android:1.10.0-alpha04
androidx.compose.ui:ui-geometry-android:1.10.0-alpha04
androidx.compose.ui:ui-geometry:1.10.0-alpha04
androidx.compose.ui:ui-graphics-android:1.10.0-alpha04
androidx.compose.ui:ui-graphics:1.10.0-alpha04
androidx.compose.ui:ui-text-android:1.10.0-alpha04
androidx.compose.ui:ui-text:1.10.0-alpha04
androidx.compose.ui:ui-tooling-preview-android:1.10.0-alpha04
androidx.compose.ui:ui-tooling-preview:1.10.0-alpha04
androidx.compose.ui:ui-unit-android:1.10.0-alpha04
androidx.compose.ui:ui-unit:1.10.0-alpha04
androidx.compose.ui:ui-util-android:1.10.0-alpha04
androidx.compose.ui:ui-util:1.10.0-alpha04
androidx.compose.ui:ui:1.10.0-alpha04
androidx.compose:compose-bom-alpha:2025.09.01
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.13.1
androidx.core:core:1.13.1
androidx.core:core-ktx:1.16.0
androidx.core:core-viewtree:1.0.0
androidx.core:core:1.16.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0
androidx.emoji2:emoji2:1.3.0
androidx.documentfile:documentfile:1.0.0
androidx.dynamicanimation:dynamicanimation:1.0.0
androidx.emoji2:emoji2:1.4.0
androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.1
androidx.graphics:graphics-path:1.0.1
androidx.graphics:graphics-shapes-android:1.0.1
androidx.graphics:graphics-shapes:1.0.1
androidx.interpolator:interpolator:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.8.3
androidx.lifecycle:lifecycle-common-jvm:2.8.3
androidx.lifecycle:lifecycle-common:2.8.3
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.3
androidx.lifecycle:lifecycle-livedata-core:2.8.3
androidx.lifecycle:lifecycle-livedata:2.8.3
androidx.lifecycle:lifecycle-process:2.8.3
androidx.lifecycle:lifecycle-runtime-android:2.8.3
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.3
androidx.lifecycle:lifecycle-runtime-compose:2.8.3
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.3
androidx.lifecycle:lifecycle-runtime-ktx:2.8.3
androidx.lifecycle:lifecycle-runtime:2.8.3
androidx.lifecycle:lifecycle-viewmodel-android:2.8.3
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.3
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.3
androidx.lifecycle:lifecycle-viewmodel:2.8.3
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.9.4
androidx.lifecycle:lifecycle-common-jvm:2.9.4
androidx.lifecycle:lifecycle-common:2.9.4
androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4
androidx.lifecycle:lifecycle-livedata-core:2.9.4
androidx.lifecycle:lifecycle-livedata:2.9.4
androidx.lifecycle:lifecycle-process:2.9.4
androidx.lifecycle:lifecycle-runtime-android:2.9.4
androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4
androidx.lifecycle:lifecycle-runtime-compose:2.9.4
androidx.lifecycle:lifecycle-runtime-ktx-android:2.9.4
androidx.lifecycle:lifecycle-runtime-ktx:2.9.4
androidx.lifecycle:lifecycle-runtime:2.9.4
androidx.lifecycle:lifecycle-viewmodel-android:2.9.4
androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4
androidx.lifecycle:lifecycle-viewmodel:2.9.4
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-beta01
androidx.profileinstaller:profileinstaller:1.3.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1
androidx.print:print:1.0.0
androidx.profileinstaller:profileinstaller:1.4.0
androidx.savedstate:savedstate-android:1.3.2
androidx.savedstate:savedstate-compose-android:1.3.2
androidx.savedstate:savedstate-compose:1.3.2
androidx.savedstate:savedstate-ktx:1.3.2
androidx.savedstate:savedstate:1.3.2
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.3.0-alpha02
androidx.tracing:tracing:1.3.0-alpha02
androidx.transition:transition:1.6.0
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0
androidx.window:window-core:1.3.0
androidx.window:window:1.3.0
androidx.window:window-core-android:1.4.0
androidx.window:window-core:1.4.0
androidx.window:window:1.4.0
com.google.accompanist:accompanist-drawablepainter:0.32.0
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.53.1
com.google.dagger:dagger:2.53.1
com.google.dagger:hilt-android:2.53.1
com.google.dagger:hilt-core:2.53.1
com.google.dagger:dagger-lint-aar:2.57.2
com.google.dagger:dagger:2.57.2
com.google.dagger:hilt-android:2.57.2
com.google.dagger:hilt-core:2.57.2
com.google.guava:listenablefuture:1.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.9.0
@ -110,15 +126,18 @@ io.coil-kt:coil-compose:2.7.0
io.coil-kt:coil:2.7.0
jakarta.inject:jakarta.inject-api:2.0.1
javax.inject:javax.inject:1
org.jetbrains.kotlin:kotlin-stdlib-common:2.1.0
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.1.0
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.1
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1
org.jetbrains.kotlin:kotlin-stdlib:2.2.21
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1
org.jetbrains.kotlinx:kotlinx-datetime:0.6.1
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3
org.jetbrains:annotations:23.0.0
org.jspecify:jspecify:1.0.0

@ -1,3 +1,178 @@
# :app module
## Dependency graph
![Dependency graph](../docs/images/graphs/dep_graph_app.svg)
# `:app`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :feature
direction TB
subgraph :feature:settings
direction TB
:feature:settings:impl[impl]:::android-library
end
subgraph :feature:foryou
direction TB
:feature:foryou:api[api]:::android-library
:feature:foryou:impl[impl]:::android-library
end
subgraph :feature:bookmarks
direction TB
:feature:bookmarks:api[api]:::android-library
:feature:bookmarks:impl[impl]:::android-library
end
subgraph :feature:search
direction TB
:feature:search:api[api]:::android-library
:feature:search:impl[impl]:::android-library
end
subgraph :feature:interests
direction TB
:feature:interests:api[api]:::android-library
:feature:interests:impl[impl]:::android-library
end
subgraph :feature:topic
direction TB
:feature:topic:api[api]:::android-library
:feature:topic:impl[impl]:::android-library
end
end
subgraph :sync
direction TB
:sync:work[work]:::android-library
end
subgraph :core
direction TB
:core:analytics[analytics]:::android-library
:core:common[common]:::jvm-library
:core:data[data]:::android-library
:core:database[database]:::android-library
:core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::android-library
:core:designsystem[designsystem]:::android-library
:core:domain[domain]:::android-library
:core:model[model]:::jvm-library
:core:navigation[navigation]:::android-library
:core:network[network]:::android-library
:core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library
end
:benchmarks[benchmarks]:::android-test
:app[app]:::android-application
:app -.->|baselineProfile| :benchmarks
:app -.-> :core:analytics
:app -.-> :core:common
:app -.-> :core:data
:app -.-> :core:designsystem
:app -.-> :core:model
:app -.-> :core:ui
:app -.-> :feature:bookmarks:api
:app -.-> :feature:bookmarks:impl
:app -.-> :feature:foryou:api
:app -.-> :feature:foryou:impl
:app -.-> :feature:interests:api
:app -.-> :feature:interests:impl
:app -.-> :feature:search:api
:app -.-> :feature:search:impl
:app -.-> :feature:settings:impl
:app -.-> :feature:topic:api
:app -.-> :feature:topic:impl
:app -.-> :sync:work
:benchmarks -.->|testedApks| :app
:core:data -.-> :core:analytics
:core:data --> :core:common
:core:data --> :core:database
:core:data --> :core:datastore
:core:data --> :core:network
:core:data -.-> :core:notifications
:core:database --> :core:model
:core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto
:core:datastore --> :core:model
:core:domain --> :core:data
:core:domain --> :core:model
:core:network --> :core:common
:core:network --> :core:model
:core:notifications -.-> :core:common
:core:notifications --> :core:model
:core:ui --> :core:analytics
:core:ui --> :core:designsystem
:core:ui --> :core:model
:feature:bookmarks:api --> :core:navigation
:feature:bookmarks:impl -.-> :core:data
:feature:bookmarks:impl -.-> :core:designsystem
:feature:bookmarks:impl -.-> :core:ui
:feature:bookmarks:impl -.-> :feature:bookmarks:api
:feature:bookmarks:impl -.-> :feature:topic:api
:feature:foryou:api --> :core:navigation
:feature:foryou:impl -.-> :core:designsystem
:feature:foryou:impl -.-> :core:domain
:feature:foryou:impl -.-> :core:notifications
:feature:foryou:impl -.-> :core:ui
:feature:foryou:impl -.-> :feature:foryou:api
:feature:foryou:impl -.-> :feature:topic:api
:feature:interests:api --> :core:navigation
:feature:interests:impl -.-> :core:designsystem
:feature:interests:impl -.-> :core:domain
:feature:interests:impl -.-> :core:ui
:feature:interests:impl -.-> :feature:interests:api
:feature:interests:impl -.-> :feature:topic:api
:feature:search:api -.-> :core:domain
:feature:search:api --> :core:navigation
:feature:search:impl -.-> :core:designsystem
:feature:search:impl -.-> :core:domain
:feature:search:impl -.-> :core:ui
:feature:search:impl -.-> :feature:interests:api
:feature:search:impl -.-> :feature:search:api
:feature:search:impl -.-> :feature:topic:api
:feature:settings:impl -.-> :core:data
:feature:settings:impl -.-> :core:designsystem
:feature:settings:impl -.-> :core:ui
:feature:topic:api -.-> :core:designsystem
:feature:topic:api --> :core:navigation
:feature:topic:api -.-> :core:ui
:feature:topic:impl -.-> :core:data
:feature:topic:impl -.-> :core:designsystem
:feature:topic:impl -.-> :core:ui
:feature:topic:impl -.-> :feature:topic:api
:sync:work -.-> :core:analytics
:sync:work -.-> :core:data
:sync:work -.-> :core:notifications
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -22,7 +22,7 @@ plugins {
alias(libs.plugins.nowinandroid.android.application.jacoco)
alias(libs.plugins.nowinandroid.android.application.firebase)
alias(libs.plugins.nowinandroid.hilt)
id("com.google.android.gms.oss-licenses-plugin")
alias(libs.plugins.google.osslicenses)
alias(libs.plugins.baselineprofile)
alias(libs.plugins.roborazzi)
alias(libs.plugins.kotlin.serialization)
@ -43,9 +43,11 @@ android {
applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix
}
release {
isMinifyEnabled = true
isMinifyEnabled = providers.gradleProperty("minifyWithR8")
.map(String::toBooleanStrict).getOrElse(true)
applicationIdSuffix = NiaBuildType.RELEASE.applicationIdSuffix
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro")
// To publish on the Play store a private signing key is required, but to allow anyone
// who clones the code to sign and run the release variant, use the debug signing key.
@ -61,21 +63,22 @@ android {
excludes.add("/META-INF/{AL2.0,LGPL2.1}")
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
testOptions.unitTests.isIncludeAndroidResources = true
namespace = "com.google.samples.apps.nowinandroid"
}
dependencies {
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.feature.interests.api)
implementation(projects.feature.interests.impl)
implementation(projects.feature.foryou.api)
implementation(projects.feature.foryou.impl)
implementation(projects.feature.bookmarks.api)
implementation(projects.feature.bookmarks.impl)
implementation(projects.feature.topic.api)
implementation(projects.feature.topic.impl)
implementation(projects.feature.search.api)
implementation(projects.feature.search.impl)
implementation(projects.feature.settings.impl)
implementation(projects.core.common)
implementation(projects.core.ui)
@ -90,13 +93,13 @@ dependencies {
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.adaptive.navigation3)
implementation(libs.androidx.compose.material3.windowSizeClass)
implementation(libs.androidx.compose.runtime.tracing)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewModel.navigation3)
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.window.core)
@ -117,15 +120,16 @@ dependencies {
testImplementation(projects.sync.syncTest)
testImplementation(libs.kotlin.test)
testDemoImplementation(libs.androidx.navigation.testing)
testDemoImplementation(libs.robolectric)
testDemoImplementation(libs.roborazzi)
testDemoImplementation(projects.core.screenshotTesting)
testDemoImplementation(projects.core.testing)
androidTestImplementation(projects.core.testing)
androidTestImplementation(projects.core.dataTest)
androidTestImplementation(projects.core.datastoreTest)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(libs.androidx.navigation.testing)
androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.kotlin.test)

@ -1,147 +1,172 @@
androidx.activity:activity-compose:1.9.3
androidx.activity:activity-ktx:1.9.3
androidx.activity:activity:1.9.3
androidx.annotation:annotation-experimental:1.4.1
androidx.annotation:annotation-jvm:1.8.1
androidx.annotation:annotation:1.8.1
androidx.activity:activity-compose:1.12.0
androidx.activity:activity-ktx:1.12.0
androidx.activity:activity:1.12.0
androidx.annotation:annotation-experimental:1.5.1
androidx.annotation:annotation-jvm:1.9.1
androidx.annotation:annotation:1.9.1
androidx.appcompat:appcompat-resources:1.7.0
androidx.appcompat:appcompat:1.7.0
androidx.arch.core:core-common:2.2.0
androidx.arch.core:core-runtime:2.2.0
androidx.autofill:autofill:1.0.0
androidx.browser:browser:1.8.0
androidx.collection:collection-jvm:1.4.4
androidx.collection:collection-ktx:1.4.4
androidx.collection:collection:1.4.4
androidx.compose.animation:animation-android:1.7.6
androidx.compose.animation:animation-core-android:1.7.6
androidx.compose.animation:animation-core:1.7.6
androidx.compose.animation:animation:1.7.6
androidx.compose.foundation:foundation-android:1.7.6
androidx.compose.foundation:foundation-layout-android:1.7.6
androidx.compose.foundation:foundation-layout:1.7.6
androidx.compose.foundation:foundation:1.7.6
androidx.compose.material3.adaptive:adaptive-android:1.0.0
androidx.compose.material3.adaptive:adaptive-layout-android:1.0.0
androidx.compose.material3.adaptive:adaptive-layout:1.0.0
androidx.compose.material3.adaptive:adaptive-navigation-android:1.0.0
androidx.compose.material3.adaptive:adaptive-navigation:1.0.0
androidx.compose.material3.adaptive:adaptive:1.0.0
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.3.1
androidx.compose.material3:material3-adaptive-navigation-suite:1.3.1
androidx.compose.material3:material3-android:1.3.1
androidx.compose.material3:material3-window-size-class-android:1.3.1
androidx.compose.material3:material3-window-size-class:1.3.1
androidx.compose.material3:material3:1.3.1
androidx.compose.material:material-icons-core-android:1.7.6
androidx.compose.material:material-icons-core:1.7.6
androidx.compose.material:material-icons-extended-android:1.7.6
androidx.compose.material:material-icons-extended:1.7.6
androidx.compose.material:material-ripple-android:1.7.6
androidx.compose.material:material-ripple:1.7.6
androidx.compose.runtime:runtime-android:1.7.6
androidx.compose.runtime:runtime-saveable-android:1.7.6
androidx.compose.runtime:runtime-saveable:1.7.6
androidx.compose.runtime:runtime-tracing:1.7.6
androidx.compose.runtime:runtime:1.7.6
androidx.compose.ui:ui-android:1.7.6
androidx.compose.ui:ui-geometry-android:1.7.6
androidx.compose.ui:ui-geometry:1.7.6
androidx.compose.ui:ui-graphics-android:1.7.6
androidx.compose.ui:ui-graphics:1.7.6
androidx.compose.ui:ui-text-android:1.7.6
androidx.compose.ui:ui-text:1.7.6
androidx.compose.ui:ui-tooling-preview-android:1.7.6
androidx.compose.ui:ui-tooling-preview:1.7.6
androidx.compose.ui:ui-unit-android:1.7.6
androidx.compose.ui:ui-unit:1.7.6
androidx.compose.ui:ui-util-android:1.7.6
androidx.compose.ui:ui-util:1.7.6
androidx.compose.ui:ui:1.7.6
androidx.compose:compose-bom:2024.12.01
androidx.collection:collection-jvm:1.5.0
androidx.collection:collection-ktx:1.5.0
androidx.collection:collection:1.5.0
androidx.compose.animation:animation-android:1.10.0-beta02
androidx.compose.animation:animation-core-android:1.10.0-beta02
androidx.compose.animation:animation-core:1.10.0-beta02
androidx.compose.animation:animation:1.10.0-beta02
androidx.compose.foundation:foundation-android:1.10.0-beta02
androidx.compose.foundation:foundation-layout-android:1.10.0-beta02
androidx.compose.foundation:foundation-layout:1.10.0-beta02
androidx.compose.foundation:foundation:1.10.0-beta02
androidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation3-android:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation3:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha04
androidx.compose.material3.adaptive:adaptive:1.3.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha04
androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha04
androidx.compose.material3:material3-android:1.5.0-alpha04
androidx.compose.material3:material3-window-size-class-android:1.5.0-alpha04
androidx.compose.material3:material3-window-size-class:1.5.0-alpha04
androidx.compose.material3:material3:1.5.0-alpha04
androidx.compose.material:material-icons-core-android:1.7.8
androidx.compose.material:material-icons-core:1.7.8
androidx.compose.material:material-icons-extended-android:1.7.8
androidx.compose.material:material-icons-extended:1.7.8
androidx.compose.material:material-ripple-android:1.10.0-alpha04
androidx.compose.material:material-ripple:1.10.0-alpha04
androidx.compose.runtime:runtime-android:1.10.0-beta02
androidx.compose.runtime:runtime-annotation-android:1.10.0-beta02
androidx.compose.runtime:runtime-annotation:1.10.0-beta02
androidx.compose.runtime:runtime-retain-android:1.10.0-beta02
androidx.compose.runtime:runtime-retain:1.10.0-beta02
androidx.compose.runtime:runtime-saveable-android:1.10.0-beta02
androidx.compose.runtime:runtime-saveable:1.10.0-beta02
androidx.compose.runtime:runtime-tracing:1.10.0-beta02
androidx.compose.runtime:runtime:1.10.0-beta02
androidx.compose.ui:ui-android:1.10.0-beta02
androidx.compose.ui:ui-geometry-android:1.10.0-beta02
androidx.compose.ui:ui-geometry:1.10.0-beta02
androidx.compose.ui:ui-graphics-android:1.10.0-beta02
androidx.compose.ui:ui-graphics:1.10.0-beta02
androidx.compose.ui:ui-text-android:1.10.0-beta02
androidx.compose.ui:ui-text:1.10.0-beta02
androidx.compose.ui:ui-tooling-preview-android:1.10.0-beta02
androidx.compose.ui:ui-tooling-preview:1.10.0-beta02
androidx.compose.ui:ui-unit-android:1.10.0-beta02
androidx.compose.ui:ui-unit:1.10.0-beta02
androidx.compose.ui:ui-util-android:1.10.0-beta02
androidx.compose.ui:ui-util:1.10.0-beta02
androidx.compose.ui:ui:1.10.0-beta02
androidx.compose:compose-bom-alpha:2025.09.01
androidx.concurrent:concurrent-futures-ktx:1.1.0
androidx.concurrent:concurrent-futures:1.1.0
androidx.core:core-ktx:1.15.0
androidx.core:core-ktx:1.16.0
androidx.core:core-splashscreen:1.0.1
androidx.core:core:1.15.0
androidx.core:core-viewtree:1.0.0
androidx.core:core:1.16.0
androidx.cursoradapter:cursoradapter:1.0.0
androidx.customview:customview-poolingcontainer:1.0.0
androidx.customview:customview:1.0.0
androidx.datastore:datastore-android:1.1.1
androidx.datastore:datastore-core-android:1.1.1
androidx.datastore:datastore-core-okio-jvm:1.1.1
androidx.datastore:datastore-core-okio:1.1.1
androidx.datastore:datastore-core:1.1.1
androidx.datastore:datastore-preferences-android:1.1.1
androidx.datastore:datastore-preferences-core-jvm:1.1.1
androidx.datastore:datastore-preferences-core:1.1.1
androidx.datastore:datastore-preferences:1.1.1
androidx.datastore:datastore:1.1.1
androidx.datastore:datastore-android:1.2.0
androidx.datastore:datastore-core-android:1.2.0
androidx.datastore:datastore-core-okio-jvm:1.2.0
androidx.datastore:datastore-core-okio:1.2.0
androidx.datastore:datastore-core:1.2.0
androidx.datastore:datastore-preferences-android:1.2.0
androidx.datastore:datastore-preferences-core-android:1.2.0
androidx.datastore:datastore-preferences-core:1.2.0
androidx.datastore:datastore-preferences-external-protobuf:1.2.0
androidx.datastore:datastore-preferences-proto:1.2.0
androidx.datastore:datastore-preferences:1.2.0
androidx.datastore:datastore:1.2.0
androidx.documentfile:documentfile:1.0.0
androidx.drawerlayout:drawerlayout:1.0.0
androidx.emoji2:emoji2-views-helper:1.3.0
androidx.emoji2:emoji2:1.3.0
androidx.dynamicanimation:dynamicanimation:1.0.0
androidx.emoji2:emoji2-views-helper:1.4.0
androidx.emoji2:emoji2:1.4.0
androidx.exifinterface:exifinterface:1.3.7
androidx.fragment:fragment:1.5.4
androidx.graphics:graphics-path:1.0.1
androidx.graphics:graphics-shapes-android:1.0.1
androidx.graphics:graphics-shapes:1.0.1
androidx.hilt:hilt-common:1.2.0
androidx.hilt:hilt-navigation-compose:1.2.0
androidx.hilt:hilt-navigation:1.2.0
androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0-alpha02
androidx.hilt:hilt-lifecycle-viewmodel:1.3.0-alpha02
androidx.hilt:hilt-work:1.2.0
androidx.interpolator:interpolator:1.0.0
androidx.legacy:legacy-support-core-utils:1.0.0
androidx.lifecycle:lifecycle-common-java8:2.8.7
androidx.lifecycle:lifecycle-common-jvm:2.8.7
androidx.lifecycle:lifecycle-common:2.8.7
androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.7
androidx.lifecycle:lifecycle-livedata-core:2.8.7
androidx.lifecycle:lifecycle-livedata:2.8.7
androidx.lifecycle:lifecycle-process:2.8.7
androidx.lifecycle:lifecycle-runtime-android:2.8.7
androidx.lifecycle:lifecycle-runtime-compose-android:2.8.7
androidx.lifecycle:lifecycle-runtime-compose:2.8.7
androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.7
androidx.lifecycle:lifecycle-runtime-ktx:2.8.7
androidx.lifecycle:lifecycle-runtime:2.8.7
androidx.lifecycle:lifecycle-service:2.8.7
androidx.lifecycle:lifecycle-viewmodel-android:2.8.7
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.8.7
androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7
androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.7
androidx.lifecycle:lifecycle-viewmodel:2.8.7
androidx.lifecycle:lifecycle-common-java8:2.10.0
androidx.lifecycle:lifecycle-common-jvm:2.10.0
androidx.lifecycle:lifecycle-common:2.10.0
androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0
androidx.lifecycle:lifecycle-livedata-core:2.10.0
androidx.lifecycle:lifecycle-livedata:2.10.0
androidx.lifecycle:lifecycle-process:2.10.0
androidx.lifecycle:lifecycle-runtime-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0
androidx.lifecycle:lifecycle-runtime-compose:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0
androidx.lifecycle:lifecycle-runtime-ktx:2.10.0
androidx.lifecycle:lifecycle-runtime:2.10.0
androidx.lifecycle:lifecycle-service:2.10.0
androidx.lifecycle:lifecycle-viewmodel-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0
androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0
androidx.lifecycle:lifecycle-viewmodel-navigation3-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0
androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0
androidx.lifecycle:lifecycle-viewmodel:2.10.0
androidx.loader:loader:1.0.0
androidx.localbroadcastmanager:localbroadcastmanager:1.0.0
androidx.metrics:metrics-performance:1.0.0-beta01
androidx.navigation:navigation-common-ktx:2.8.5
androidx.navigation:navigation-common:2.8.5
androidx.navigation:navigation-compose:2.8.5
androidx.navigation:navigation-runtime-ktx:2.8.5
androidx.navigation:navigation-runtime:2.8.5
androidx.navigation3:navigation3-runtime-android:1.0.0
androidx.navigation3:navigation3-runtime:1.0.0
androidx.navigation3:navigation3-ui-android:1.0.0
androidx.navigation3:navigation3-ui:1.0.0
androidx.navigationevent:navigationevent-android:1.0.0
androidx.navigationevent:navigationevent-compose-android:1.0.0
androidx.navigationevent:navigationevent-compose:1.0.0
androidx.navigationevent:navigationevent:1.0.0
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.4.1
androidx.resourceinspection:resourceinspection-annotation:1.0.1
androidx.room:room-common:2.6.1
androidx.room:room-ktx:2.6.1
androidx.room:room-runtime:2.6.1
androidx.savedstate:savedstate-ktx:1.2.1
androidx.savedstate:savedstate:1.2.1
androidx.sqlite:sqlite-framework:2.4.0
androidx.sqlite:sqlite:2.4.0
androidx.room:room-common-jvm:2.8.3
androidx.room:room-common:2.8.3
androidx.room:room-ktx:2.8.3
androidx.room:room-runtime-android:2.8.3
androidx.room:room-runtime:2.8.3
androidx.savedstate:savedstate-android:1.4.0
androidx.savedstate:savedstate-compose-android:1.4.0
androidx.savedstate:savedstate-compose:1.4.0
androidx.savedstate:savedstate-ktx:1.4.0
androidx.savedstate:savedstate:1.4.0
androidx.sqlite:sqlite-android:2.6.1
androidx.sqlite:sqlite-framework-android:2.6.1
androidx.sqlite:sqlite-framework:2.6.1
androidx.sqlite:sqlite:2.6.1
androidx.startup:startup-runtime:1.1.1
androidx.tracing:tracing-ktx:1.3.0-alpha02
androidx.tracing:tracing-perfetto:1.0.0
androidx.tracing:tracing:1.3.0-alpha02
androidx.transition:transition:1.6.0
androidx.vectordrawable:vectordrawable-animated:1.1.0
androidx.vectordrawable:vectordrawable:1.1.0
androidx.versionedparcelable:versionedparcelable:1.1.1
androidx.viewpager:viewpager:1.0.0
androidx.window.extensions.core:core:1.0.0
androidx.window:window-core-android:1.3.0
androidx.window:window-core:1.3.0
androidx.window:window:1.3.0
androidx.window:window-core-android:1.5.0
androidx.window:window-core:1.5.0
androidx.window:window:1.5.0
androidx.work:work-runtime-ktx:2.10.0
androidx.work:work-runtime:2.10.0
com.caverock:androidsvg-aar:1.4
@ -164,10 +189,10 @@ com.google.android.gms:play-services-oss-licenses:17.1.0
com.google.android.gms:play-services-stats:17.0.2
com.google.android.gms:play-services-tasks:18.2.0
com.google.code.findbugs:jsr305:3.0.2
com.google.dagger:dagger-lint-aar:2.53.1
com.google.dagger:dagger:2.53.1
com.google.dagger:hilt-android:2.53.1
com.google.dagger:hilt-core:2.53.1
com.google.dagger:dagger-lint-aar:2.57.2
com.google.dagger:dagger:2.57.2
com.google.dagger:hilt-android:2.57.2
com.google.dagger:hilt-core:2.57.2
com.google.errorprone:error_prone_annotations:2.26.0
com.google.firebase:firebase-abt:21.1.1
com.google.firebase:firebase-analytics:22.1.2
@ -198,8 +223,8 @@ com.google.protobuf:protobuf-javalite:4.29.2
com.google.protobuf:protobuf-kotlin-lite:4.29.2
com.squareup.okhttp3:logging-interceptor:4.12.0
com.squareup.okhttp3:okhttp:4.12.0
com.squareup.okio:okio-jvm:3.9.0
com.squareup.okio:okio:3.9.0
com.squareup.okio:okio-jvm:3.9.1
com.squareup.okio:okio:3.9.1
com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0
com.squareup.retrofit2:retrofit:2.11.0
io.coil-kt:coil-base:2.7.0
@ -210,24 +235,22 @@ io.coil-kt:coil:2.7.0
jakarta.inject:jakarta.inject-api:2.0.1
javax.inject:javax.inject:1
org.checkerframework:checker-qual:3.12.0
org.jetbrains.kotlin:kotlin-android-extensions-runtime:1.9.22
org.jetbrains.kotlin:kotlin-parcelize-runtime:1.9.22
org.jetbrains.kotlin:kotlin-stdlib-common:2.1.0
org.jetbrains.kotlin:kotlin-stdlib-common:2.2.21
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.0
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.1.0
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.9.0
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0
org.jetbrains.kotlin:kotlin-stdlib:2.2.21
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.1
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.1
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1
org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.1
org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.1
org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.6.1
org.jetbrains.kotlinx:kotlinx-datetime:0.6.1
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3
org.jetbrains.kotlinx:kotlinx-serialization-bom:1.8.0
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.0
org.jetbrains.kotlinx:kotlinx-serialization-core:1.8.0
org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.8.0
org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0
org.jetbrains:annotations:23.0.0
org.jspecify:jspecify:1.0.0

@ -1,6 +1,6 @@
package: name='com.google.samples.apps.nowinandroid' versionCode='8' versionName='0.1.2' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15'
sdkVersion:'21'
targetSdkVersion:'35'
package: name='com.google.samples.apps.nowinandroid' versionCode='8' versionName='0.1.2' platformBuildVersionName='16' platformBuildVersionCode='36' compileSdkVersion='36' compileSdkVersionCodename='16'
minSdkVersion:'23'
targetSdkVersion:'36'
uses-permission: name='android.permission.INTERNET'
uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
uses-permission: name='android.permission.POST_NOTIFICATIONS'

@ -0,0 +1,2 @@
# Repackage classes into the default package to reduce the size of descriptors.
-repackageclasses

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.ui.semantics.SemanticsActions.ScrollBy
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasTestTag
@ -39,18 +40,20 @@ import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule
import com.google.samples.apps.nowinandroid.feature.interests.impl.LIST_PANE_TEST_TAG
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import javax.inject.Inject
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.search.R as FeatureSearchR
import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as BookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.api.R as FeatureForyouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as FeatureSearchR
import com.google.samples.apps.nowinandroid.feature.settings.impl.R as SettingsR
/**
* Tests all the navigation flows that are handled by the navigation library.
@ -83,15 +86,15 @@ class NavigationTest {
lateinit var newsRepository: NewsRepository
// The strings used for matching in these tests
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 navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_navigate_up)
private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_title)
private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_api_interests)
private val sampleTopic = "Headlines"
private val appName by composeTestRule.stringResource(R.string.app_name)
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)
private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_api_title)
private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_top_app_bar_action_icon_description)
private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_brand_android)
private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_impl_dismiss_dialog_button_text)
@Before
fun setup() = hiltRule.inject()
@ -252,6 +255,9 @@ class NavigationTest {
}
}
// TODO decide if backStack should preserve previous stacks when navigating back to home tab (ForYou)
// https://github.com/android/nowinandroid/issues/1937
@Ignore
@Test
fun navigationBar_multipleBackStackInterests() {
composeTestRule.apply {
@ -261,12 +267,14 @@ class NavigationTest {
val topic = runBlocking {
topicsRepository.getTopics().first().sortedBy(Topic::name).last()
}
onNodeWithTag("interests:topics").performScrollToNode(hasText(topic.name))
onNodeWithTag(LIST_PANE_TEST_TAG).performScrollToNode(hasText(topic.name))
onNodeWithText(topic.name).performClick()
// Verify the topic is still shown
onNodeWithTag("topic:${topic.id}").assertIsDisplayed()
// Switch tab
onNodeWithText(forYou).performClick()
// Come back to Interests
onNodeWithText(interests).performClick()

@ -66,7 +66,7 @@ class NiaApplication : Application(), ImageLoaderFactory {
private fun setStrictModePolicy() {
if (isDebuggable()) {
StrictMode.setThreadPolicy(
Builder().detectAll().penaltyLog().penaltyDeath().build(),
Builder().detectAll().penaltyLog().build(),
)
}
}

@ -1,72 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.navigation
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.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.ui.NiaAppState
import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen
/**
* Top-level navigation graph. Navigation is organized as explained at
* https://d.android.com/jetpack/compose/nav-adaptive
*
* The navigation graph defined in this file defines the different top level routes. Navigation
* within each route is handled using state and Back Handlers.
*/
@Composable
fun NiaNavHost(
appState: NiaAppState,
onShowSnackbar: suspend (String, String?) -> Boolean,
modifier: Modifier = Modifier,
) {
val navController = appState.navController
NavHost(
navController = navController,
startDestination = ForYouBaseRoute,
modifier = modifier,
) {
forYouSection(
onTopicClick = navController::navigateToTopic,
) {
topicScreen(
showBackButton = true,
onBackClick = navController::popBackStack,
onTopicClick = navController::navigateToTopic,
)
}
bookmarksScreen(
onTopicClick = navController::navigateToInterests,
onShowSnackbar = onShowSnackbar,
)
searchScreen(
onBackClick = navController::popBackStack,
onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) },
onTopicClick = navController::navigateToInterests,
)
interestsListDetailScreen()
}
}

@ -1,76 +0,0 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
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.navigation.BookmarksRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.R as searchR
/**
* Type for the top level destinations in the application. Contains metadata about the destination
* that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
* @param route The route to use when navigating to this destination.
* @param baseRoute The highest ancestor of this destination. Defaults to [route], meaning that
* there is a single destination in that section of the app (no nested destinations).
*/
enum class TopLevelDestination(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
val route: KClass<*>,
val baseRoute: KClass<*> = route,
) {
FOR_YOU(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_title,
titleTextId = R.string.app_name,
route = ForYouRoute::class,
baseRoute = ForYouBaseRoute::class,
),
BOOKMARKS(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_title,
titleTextId = bookmarksR.string.feature_bookmarks_title,
route = BookmarksRoute::class,
),
INTERESTS(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_interests,
titleTextId = searchR.string.feature_search_interests,
route = InterestsRoute::class,
),
}

@ -0,0 +1,73 @@
/*
* Copyright 2025 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.navigation
import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
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.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as bookmarksR
import com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR
import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR
/**
* Type for the top level navigation items in the application. Contains UI information about the
* current route that is used in the top app bar and common navigation UI.
*
* @param selectedIcon The icon to be displayed in the navigation UI when this destination is
* selected.
* @param unselectedIcon The icon to be displayed in the navigation UI when this destination is
* not selected.
* @param iconTextId Text that to be displayed in the navigation UI.
* @param titleTextId Text that is displayed on the top app bar.
*/
data class TopLevelNavItem(
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
@StringRes val iconTextId: Int,
@StringRes val titleTextId: Int,
)
val FOR_YOU = TopLevelNavItem(
selectedIcon = NiaIcons.Upcoming,
unselectedIcon = NiaIcons.UpcomingBorder,
iconTextId = forYouR.string.feature_foryou_api_title,
titleTextId = R.string.app_name,
)
val BOOKMARKS = TopLevelNavItem(
selectedIcon = NiaIcons.Bookmarks,
unselectedIcon = NiaIcons.BookmarksBorder,
iconTextId = bookmarksR.string.feature_bookmarks_api_title,
titleTextId = bookmarksR.string.feature_bookmarks_api_title,
)
val INTERESTS = TopLevelNavItem(
selectedIcon = NiaIcons.Grid3x3,
unselectedIcon = NiaIcons.Grid3x3,
iconTextId = searchR.string.feature_search_api_interests,
titleTextId = searchR.string.feature_search_api_interests,
)
val TOP_LEVEL_NAV_ITEMS = mapOf(
ForYouNavKey to FOR_YOU,
BookmarksNavKey to BOOKMARKS,
InterestsNavKey(null) to INTERESTS,
)

@ -21,7 +21,9 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
@ -31,15 +33,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarDuration.Short
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult.ActionPerformed
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -58,9 +61,9 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground
@ -69,11 +72,19 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp
import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors
import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors
import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import kotlin.reflect.KClass
import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.core.navigation.toEntries
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.bookmarksEntry
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation.forYouEntry
import com.google.samples.apps.nowinandroid.feature.interests.impl.navigation.interestsEntry
import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchNavKey
import com.google.samples.apps.nowinandroid.feature.search.impl.navigation.searchEntry
import com.google.samples.apps.nowinandroid.feature.settings.impl.SettingsDialog
import com.google.samples.apps.nowinandroid.feature.topic.impl.navigation.topicEntry
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS
import com.google.samples.apps.nowinandroid.feature.settings.impl.R as settingsR
@Composable
fun NiaApp(
@ -81,8 +92,7 @@ fun NiaApp(
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val shouldShowGradientBackground =
appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU
val shouldShowGradientBackground = appState.navigationState.currentTopLevelKey == ForYouNavKey
var showSettingsDialog by rememberSaveable { mutableStateOf(false) }
NiaBackground(modifier = modifier) {
@ -107,15 +117,17 @@ fun NiaApp(
)
}
}
CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) {
NiaApp(
appState = appState,
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = showSettingsDialog,
onSettingsDismissed = { showSettingsDialog = false },
onTopAppBarActionClick = { showSettingsDialog = true },
windowAdaptiveInfo = windowAdaptiveInfo,
)
// TODO: Settings should be a dialog screen
showSettingsDialog = showSettingsDialog,
onSettingsDismissed = { showSettingsDialog = false },
onTopAppBarActionClick = { showSettingsDialog = true },
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
}
}
}
@ -124,19 +136,18 @@ fun NiaApp(
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalComposeUiApi::class,
ExperimentalMaterial3AdaptiveApi::class,
)
internal fun NiaApp(
appState: NiaAppState,
snackbarHostState: SnackbarHostState,
showSettingsDialog: Boolean,
onSettingsDismissed: () -> Unit,
onTopAppBarActionClick: () -> Unit,
modifier: Modifier = Modifier,
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val unreadDestinations by appState.topLevelDestinationsWithUnreadResources
val unreadNavKeys by appState.topLevelNavKeysWithUnreadResources
.collectAsStateWithLifecycle()
val currentDestination = appState.currentDestination
if (showSettingsDialog) {
SettingsDialog(
@ -144,30 +155,32 @@ internal fun NiaApp(
)
}
val snackbarHostState = LocalSnackbarHostState.current
val navigator = remember { Navigator(appState.navigationState) }
NiaNavigationSuiteScaffold(
navigationSuiteItems = {
appState.topLevelDestinations.forEach { destination ->
val hasUnread = unreadDestinations.contains(destination)
val selected = currentDestination
.isRouteInHierarchy(destination.baseRoute)
TOP_LEVEL_NAV_ITEMS.forEach { (navKey, navItem) ->
val hasUnread = unreadNavKeys.contains(navKey)
val selected = navKey == appState.navigationState.currentTopLevelKey
item(
selected = selected,
onClick = { appState.navigateToTopLevelDestination(destination) },
onClick = { navigator.navigate(navKey) },
icon = {
Icon(
imageVector = destination.unselectedIcon,
imageVector = navItem.unselectedIcon,
contentDescription = null,
)
},
selectedIcon = {
Icon(
imageVector = destination.selectedIcon,
imageVector = navItem.selectedIcon,
contentDescription = null,
)
},
label = { Text(stringResource(destination.iconTextId)) },
modifier =
Modifier
label = { Text(stringResource(navItem.iconTextId)) },
modifier = Modifier
.testTag("NiaNavItem")
.then(if (hasUnread) Modifier.notificationDot() else Modifier),
)
@ -182,7 +195,16 @@ internal fun NiaApp(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbarHostState) },
snackbarHost = {
SnackbarHost(
snackbarHostState,
modifier = Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.exclude(
WindowInsets.ime,
),
),
)
},
) { padding ->
Column(
Modifier
@ -195,27 +217,30 @@ internal fun NiaApp(
),
),
) {
// Show the top app bar on top level destinations.
val destination = appState.currentTopLevelDestination
// Only show the top app bar on top level destinations.
var shouldShowTopAppBar = false
if (destination != null) {
if (appState.navigationState.currentKey in appState.navigationState.topLevelKeys) {
shouldShowTopAppBar = true
val destination = TOP_LEVEL_NAV_ITEMS[appState.navigationState.currentTopLevelKey]
?: error("Top level nav item not found for ${appState.navigationState.currentTopLevelKey}")
NiaTopAppBar(
titleRes = destination.titleTextId,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = settingsR.string.feature_settings_top_app_bar_navigation_icon_description,
id = settingsR.string.feature_settings_impl_top_app_bar_navigation_icon_description,
),
actionIcon = NiaIcons.Settings,
actionIconContentDescription = stringResource(
id = settingsR.string.feature_settings_top_app_bar_action_icon_description,
id = settingsR.string.feature_settings_impl_top_app_bar_action_icon_description,
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
),
onActionClick = { onTopAppBarActionClick() },
onNavigationClick = { appState.navigateToSearch() },
onNavigationClick = { navigator.navigate(SearchNavKey) },
)
}
@ -229,15 +254,20 @@ internal fun NiaApp(
},
),
) {
NiaNavHost(
appState = appState,
onShowSnackbar = { message, action ->
snackbarHostState.showSnackbar(
message = message,
actionLabel = action,
duration = Short,
) == ActionPerformed
},
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
val entryProvider = entryProvider {
forYouEntry(navigator)
bookmarksEntry(navigator)
interestsEntry(navigator)
topicEntry(navigator)
searchEntry(navigator)
}
NavDisplay(
entries = appState.navigationState.toEntries(entryProvider),
sceneStrategy = listDetailStrategy,
onBack = { navigator.goBack() },
)
}
@ -266,8 +296,3 @@ private fun Modifier.notificationDot(): Modifier =
)
}
}
private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) =
this?.hierarchy?.any {
it.hasRoute(route)
} ?: false

@ -18,30 +18,18 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions
import androidx.tracing.trace
import androidx.navigation3.runtime.NavKey
import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.navigation.NavigationState
import com.google.samples.apps.nowinandroid.core.navigation.rememberNavigationState
import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank
import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou
import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests
import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_NAV_ITEMS
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -56,18 +44,20 @@ fun rememberNiaAppState(
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): NiaAppState {
NavigationTrackingSideEffect(navController)
val navigationState = rememberNavigationState(ForYouNavKey, TOP_LEVEL_NAV_ITEMS.keys)
NavigationTrackingSideEffect(navigationState)
return remember(
navController,
navigationState,
coroutineScope,
networkMonitor,
userNewsResourceRepository,
timeZoneMonitor,
) {
NiaAppState(
navController = navController,
navigationState = navigationState,
coroutineScope = coroutineScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
@ -78,35 +68,12 @@ fun rememberNiaAppState(
@Stable
class NiaAppState(
val navController: NavHostController,
val navigationState: NavigationState,
coroutineScope: CoroutineScope,
networkMonitor: NetworkMonitor,
userNewsResourceRepository: UserNewsResourceRepository,
timeZoneMonitor: TimeZoneMonitor,
) {
private val previousDestination = mutableStateOf<NavDestination?>(null)
val currentDestination: NavDestination?
@Composable get() {
// Collect the currentBackStackEntryFlow as a state
val currentEntry = navController.currentBackStackEntryFlow
.collectAsState(initial = null)
// Fallback to previousDestination if currentEntry is null
return currentEntry.value?.destination.also { destination ->
if (destination != null) {
previousDestination.value = destination
}
} ?: previousDestination.value
}
val currentTopLevelDestination: TopLevelDestination?
@Composable get() {
return TopLevelDestination.entries.firstOrNull { topLevelDestination ->
currentDestination?.hasRoute(route = topLevelDestination.route) == true
}
}
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
@ -116,20 +83,14 @@ class NiaAppState(
)
/**
* Map of top level destinations to be used in the TopBar, BottomBar and NavRail. The key is the
* route.
*/
val topLevelDestinations: List<TopLevelDestination> = TopLevelDestination.entries
/**
* The top level destinations that have unread news resources.
* The top level nav keys that have unread news resources.
*/
val topLevelDestinationsWithUnreadResources: StateFlow<Set<TopLevelDestination>> =
val topLevelNavKeysWithUnreadResources: StateFlow<Set<NavKey>> =
userNewsResourceRepository.observeAllForFollowedTopics()
.combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources ->
setOfNotNull(
FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
ForYouNavKey.takeIf { forYouNewsResources.any { !it.hasBeenViewed } },
BookmarksNavKey.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } },
)
}
.stateIn(
@ -144,55 +105,15 @@ class NiaAppState(
SharingStarted.WhileSubscribed(5_000),
TimeZone.currentSystemDefault(),
)
/**
* UI logic for navigating to a top level destination in the app. Top level destinations have
* only one copy of the destination of the back stack, and save and restore state whenever you
* navigate to and from it.
*
* @param topLevelDestination: The destination the app needs to navigate to.
*/
fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) {
trace("Navigation: ${topLevelDestination.name}") {
val topLevelNavOptions = navOptions {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
when (topLevelDestination) {
FOR_YOU -> navController.navigateToForYou(topLevelNavOptions)
BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions)
INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions)
}
}
}
fun navigateToSearch() = navController.navigateToSearch()
}
/**
* Stores information about navigation events to be used with JankStats
*/
@Composable
private fun NavigationTrackingSideEffect(navController: NavHostController) {
TrackDisposableJank(navController) { metricsHolder ->
val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
metricsHolder.state?.putState("Navigation", destination.route.toString())
}
navController.addOnDestinationChangedListener(listener)
onDispose {
navController.removeOnDestinationChangedListener(listener)
}
private fun NavigationTrackingSideEffect(navigationState: NavigationState) {
TrackDisposableJank(navigationState.currentKey) { metricsHolder ->
metricsHolder.state?.putState("Navigation", navigationState.currentKey.toString())
onDispose {}
}
}

@ -1,43 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.navigation.toRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
const val TOPIC_ID_KEY = "selectedTopicId"
@HiltViewModel
class Interests2PaneViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
val route = savedStateHandle.toRoute<InterestsRoute>()
val selectedTopicId: StateFlow<String?> = savedStateHandle.getStateFlow(
key = TOPIC_ID_KEY,
initialValue = route.initialTopicId,
)
fun onTopicClick(topicId: String?) {
savedStateHandle[TOPIC_ID_KEY] = topicId
}
}

@ -1,169 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.interests2pane
import androidx.activity.compose.BackHandler
import androidx.annotation.Keep
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute
import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute
import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic
import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable internal object TopicPlaceholderRoute
// TODO: Remove @Keep when https://issuetracker.google.com/353898971 is fixed
@Keep
@Serializable internal object DetailPaneNavHostRoute
fun NavGraphBuilder.interestsListDetailScreen() {
composable<InterestsRoute> {
InterestsListDetailScreen()
}
}
@Composable
internal fun InterestsListDetailScreen(
viewModel: Interests2PaneViewModel = hiltViewModel(),
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(),
) {
val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle()
InterestsListDetailScreen(
selectedTopicId = selectedTopicId,
onTopicClick = viewModel::onTopicClick,
windowAdaptiveInfo = windowAdaptiveInfo,
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
internal fun InterestsListDetailScreen(
selectedTopicId: String?,
onTopicClick: (String) -> Unit,
windowAdaptiveInfo: WindowAdaptiveInfo,
) {
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator(
scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo),
initialDestinationHistory = listOfNotNull(
ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List),
ThreePaneScaffoldDestinationItem<Nothing>(ListDetailPaneScaffoldRole.Detail).takeIf {
selectedTopicId != null
},
),
)
BackHandler(listDetailNavigator.canNavigateBack()) {
listDetailNavigator.navigateBack()
}
var nestedNavHostStartRoute by remember {
val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute
mutableStateOf(route)
}
var nestedNavKey by rememberSaveable(
stateSaver = Saver({ it.toString() }, UUID::fromString),
) {
mutableStateOf(UUID.randomUUID())
}
val nestedNavController = key(nestedNavKey) {
rememberNavController()
}
fun onTopicClickShowDetailPane(topicId: String) {
onTopicClick(topicId)
if (listDetailNavigator.isDetailPaneVisible()) {
// If the detail pane was visible, then use the nestedNavController navigate call
// directly
nestedNavController.navigateToTopic(topicId) {
popUpTo<DetailPaneNavHostRoute>()
}
} else {
// Otherwise, recreate the NavHost entirely, and start at the new destination
nestedNavHostStartRoute = TopicRoute(id = topicId)
nestedNavKey = UUID.randomUUID()
}
listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail)
}
ListDetailPaneScaffold(
value = listDetailNavigator.scaffoldValue,
directive = listDetailNavigator.scaffoldDirective,
listPane = {
AnimatedPane {
InterestsRoute(
onTopicClick = ::onTopicClickShowDetailPane,
highlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(),
)
}
},
detailPane = {
AnimatedPane {
key(nestedNavKey) {
NavHost(
navController = nestedNavController,
startDestination = nestedNavHostStartRoute,
route = DetailPaneNavHostRoute::class,
) {
topicScreen(
showBackButton = !listDetailNavigator.isListPaneVisible(),
onBackClick = listDetailNavigator::navigateBack,
onTopicClick = ::onTopicClickShowDetailPane,
)
composable<TopicPlaceholderRoute> {
TopicDetailPlaceholder()
}
}
}
}
},
)
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isListPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
private fun <T> ThreePaneScaffoldNavigator<T>.isDetailPaneVisible(): Boolean =
scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded

@ -21,7 +21,9 @@
<style name="NightAdjusted.Theme.Nia" parent="android:Theme.Material.Light.NoActionBar" />
<!-- The final theme we use -->
<style name="Theme.Nia" parent="NightAdjusted.Theme.Nia" />
<style name="Theme.Nia" parent="NightAdjusted.Theme.Nia">
<item name="android:forceDarkAllowed" tools:targetApi="29">false</item>
</style>
<style name="NightAdjusted.Theme.Splash" parent="Theme.SplashScreen">
<item name="android:windowLightStatusBar" tools:targetApi="23">true</item>

@ -16,21 +16,21 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.navigation.NavHostController
import androidx.navigation.compose.ComposeNavigator
import androidx.navigation.compose.composable
import androidx.navigation.createGraph
import androidx.navigation.testing.TestNavHostController
import androidx.navigation3.runtime.NavBackStack
import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.navigation.NavigationState
import com.google.samples.apps.nowinandroid.core.navigation.Navigator
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor
import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor
import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksNavKey
import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouNavKey
import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsNavKey
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.HiltTestApplication
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -38,15 +38,17 @@ import kotlinx.coroutines.test.runTest
import kotlinx.datetime.TimeZone
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Tests [NiaAppState].
*
* Note: This could become an unit test if Robolectric is added to the project and the Context
* is faked.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = HiltTestApplication::class)
@HiltAndroidTest
class NiaAppStateTest {
@get:Rule
@ -63,32 +65,42 @@ class NiaAppStateTest {
// Subject under test.
private lateinit var state: NiaAppState
private fun testNavigationState() = NavigationState(
startKey = ForYouNavKey,
topLevelStack = NavBackStack(ForYouNavKey),
subStacks = mapOf(
ForYouNavKey to NavBackStack(ForYouNavKey),
BookmarksNavKey to NavBackStack(BookmarksNavKey),
),
)
@Test
fun niaAppState_currentDestination() = runTest {
var currentDestination: String? = null
val navigationState = testNavigationState()
val navigator = Navigator(navigationState)
composeTestRule.setContent {
val navController = rememberTestNavController()
state = remember(navController) {
state = remember(navigationState) {
NiaAppState(
navController = navController,
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
navigationState = navigationState,
)
}
}
// Update currentDestination whenever it changes
currentDestination = state.currentDestination?.route
assertEquals(ForYouNavKey, state.navigationState.currentTopLevelKey)
assertEquals(ForYouNavKey, state.navigationState.currentKey)
// Navigate to destination b once
LaunchedEffect(Unit) {
navController.setCurrentDestination("b")
}
}
// Navigate to another destination once
navigator.navigate(BookmarksNavKey)
composeTestRule.waitForIdle()
assertEquals("b", currentDestination)
assertEquals(BookmarksNavKey, state.navigationState.currentTopLevelKey)
assertEquals(BookmarksNavKey, state.navigationState.currentKey)
}
@Test
@ -101,21 +113,24 @@ class NiaAppStateTest {
)
}
assertEquals(3, state.topLevelDestinations.size)
assertTrue(state.topLevelDestinations[0].name.contains("for_you", true))
assertTrue(state.topLevelDestinations[1].name.contains("bookmarks", true))
assertTrue(state.topLevelDestinations[2].name.contains("interests", true))
val navigationState = state.navigationState
assertEquals(3, navigationState.topLevelKeys.size)
assertEquals(
setOf(ForYouNavKey, BookmarksNavKey, InterestsNavKey(null)),
navigationState.topLevelKeys,
)
}
@Test
fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
navigationState = testNavigationState(),
)
}
@ -131,11 +146,11 @@ class NiaAppStateTest {
fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) {
composeTestRule.setContent {
state = NiaAppState(
navController = NavHostController(LocalContext.current),
coroutineScope = backgroundScope,
networkMonitor = networkMonitor,
userNewsResourceRepository = userNewsResourceRepository,
timeZoneMonitor = timeZoneMonitor,
navigationState = testNavigationState(),
)
}
val changedTz = TimeZone.of("Europe/Prague")
@ -147,18 +162,3 @@ class NiaAppStateTest {
)
}
}
@Composable
private fun rememberTestNavController(): TestNavHostController {
val context = LocalContext.current
return remember {
TestNavHostController(context).apply {
navigatorProvider.addNavigator(ComposeNavigator())
graph = createGraph(startDestination = "a") {
composable("a") { }
composable("b") { }
composable("c") { }
}
}
}
}

@ -67,6 +67,7 @@ import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -147,9 +148,7 @@ class SnackbarInsetsScreenshotTests {
@Test
fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"insets_snackbar_compact_medium_noSnackbar",
@ -159,13 +158,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"insets_snackbar_compact_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -176,13 +173,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp,
600.dp,
"insets_snackbar_medium_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -193,13 +188,11 @@ class SnackbarInsetsScreenshotTests {
@Test
fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp,
900.dp,
"insets_snackbar_expanded_expanded",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -209,17 +202,18 @@ class SnackbarInsetsScreenshotTests {
}
private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp,
height: Dp,
screenshotName: String,
action: suspend () -> Unit,
action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) {
lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent {
CompositionLocalProvider(
// Replaces images with placeholders
LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) {
scope = rememberCoroutineScope()
@ -259,7 +253,6 @@ class SnackbarInsetsScreenshotTests {
)
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},
@ -280,7 +273,7 @@ class SnackbarInsetsScreenshotTests {
}
scope.launch {
action()
action(snackbarHostState)
}
composeTestRule.onNodeWithTag("root")

@ -40,6 +40,7 @@ import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions
import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState
import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -120,9 +121,7 @@ class SnackbarScreenshotTests {
@Test
fun phone_noSnackbar() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"snackbar_compact_medium_noSnackbar",
@ -132,13 +131,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_phone() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
400.dp,
500.dp,
"snackbar_compact_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -149,13 +146,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_foldable() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
600.dp,
600.dp,
"snackbar_medium_medium",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -166,13 +161,11 @@ class SnackbarScreenshotTests {
@Test
fun snackbarShown_tablet() {
val snackbarHostState = SnackbarHostState()
testSnackbarScreenshotWithSize(
snackbarHostState,
900.dp,
900.dp,
"snackbar_expanded_expanded",
) {
) { snackbarHostState ->
snackbarHostState.showSnackbar(
"This is a test snackbar message",
actionLabel = "Action Label",
@ -182,17 +175,19 @@ class SnackbarScreenshotTests {
}
private fun testSnackbarScreenshotWithSize(
snackbarHostState: SnackbarHostState,
width: Dp,
height: Dp,
screenshotName: String,
action: suspend () -> Unit,
action: suspend (snackbarHostState: SnackbarHostState) -> Unit,
) {
lateinit var scope: CoroutineScope
val snackbarHostState = SnackbarHostState()
composeTestRule.setContent {
CompositionLocalProvider(
// Replaces images with placeholders
LocalInspectionMode provides true,
LocalSnackbarHostState provides snackbarHostState,
) {
scope = rememberCoroutineScope()
@ -208,7 +203,6 @@ class SnackbarScreenshotTests {
)
NiaApp(
appState = appState,
snackbarHostState = snackbarHostState,
showSettingsDialog = false,
onSettingsDismissed = {},
onTopAppBarActionClick = {},
@ -227,7 +221,7 @@ class SnackbarScreenshotTests {
}
scope.launch {
action()
action(snackbarHostState)
}
composeTestRule.onRoot()

@ -0,0 +1,17 @@
#
# Copyright 2025 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.
#
sdk = 35

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

@ -0,0 +1,178 @@
# `:benchmarks`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :feature
direction TB
subgraph :feature:settings
direction TB
:feature:settings:impl[impl]:::android-library
end
subgraph :feature:foryou
direction TB
:feature:foryou:api[api]:::android-library
:feature:foryou:impl[impl]:::android-library
end
subgraph :feature:bookmarks
direction TB
:feature:bookmarks:api[api]:::android-library
:feature:bookmarks:impl[impl]:::android-library
end
subgraph :feature:search
direction TB
:feature:search:api[api]:::android-library
:feature:search:impl[impl]:::android-library
end
subgraph :feature:interests
direction TB
:feature:interests:api[api]:::android-library
:feature:interests:impl[impl]:::android-library
end
subgraph :feature:topic
direction TB
:feature:topic:api[api]:::android-library
:feature:topic:impl[impl]:::android-library
end
end
subgraph :sync
direction TB
:sync:work[work]:::android-library
end
subgraph :core
direction TB
:core:analytics[analytics]:::android-library
:core:common[common]:::jvm-library
:core:data[data]:::android-library
:core:database[database]:::android-library
:core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::android-library
:core:designsystem[designsystem]:::android-library
:core:domain[domain]:::android-library
:core:model[model]:::jvm-library
:core:navigation[navigation]:::android-library
:core:network[network]:::android-library
:core:notifications[notifications]:::android-library
:core:ui[ui]:::android-library
end
:benchmarks[benchmarks]:::android-test
:app[app]:::android-application
:app -.->|baselineProfile| :benchmarks
:app -.-> :core:analytics
:app -.-> :core:common
:app -.-> :core:data
:app -.-> :core:designsystem
:app -.-> :core:model
:app -.-> :core:ui
:app -.-> :feature:bookmarks:api
:app -.-> :feature:bookmarks:impl
:app -.-> :feature:foryou:api
:app -.-> :feature:foryou:impl
:app -.-> :feature:interests:api
:app -.-> :feature:interests:impl
:app -.-> :feature:search:api
:app -.-> :feature:search:impl
:app -.-> :feature:settings:impl
:app -.-> :feature:topic:api
:app -.-> :feature:topic:impl
:app -.-> :sync:work
:benchmarks -.->|testedApks| :app
:core:data -.-> :core:analytics
:core:data --> :core:common
:core:data --> :core:database
:core:data --> :core:datastore
:core:data --> :core:network
:core:data -.-> :core:notifications
:core:database --> :core:model
:core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto
:core:datastore --> :core:model
:core:domain --> :core:data
:core:domain --> :core:model
:core:network --> :core:common
:core:network --> :core:model
:core:notifications -.-> :core:common
:core:notifications --> :core:model
:core:ui --> :core:analytics
:core:ui --> :core:designsystem
:core:ui --> :core:model
:feature:bookmarks:api --> :core:navigation
:feature:bookmarks:impl -.-> :core:data
:feature:bookmarks:impl -.-> :core:designsystem
:feature:bookmarks:impl -.-> :core:ui
:feature:bookmarks:impl -.-> :feature:bookmarks:api
:feature:bookmarks:impl -.-> :feature:topic:api
:feature:foryou:api --> :core:navigation
:feature:foryou:impl -.-> :core:designsystem
:feature:foryou:impl -.-> :core:domain
:feature:foryou:impl -.-> :core:notifications
:feature:foryou:impl -.-> :core:ui
:feature:foryou:impl -.-> :feature:foryou:api
:feature:foryou:impl -.-> :feature:topic:api
:feature:interests:api --> :core:navigation
:feature:interests:impl -.-> :core:designsystem
:feature:interests:impl -.-> :core:domain
:feature:interests:impl -.-> :core:ui
:feature:interests:impl -.-> :feature:interests:api
:feature:interests:impl -.-> :feature:topic:api
:feature:search:api -.-> :core:domain
:feature:search:api --> :core:navigation
:feature:search:impl -.-> :core:designsystem
:feature:search:impl -.-> :core:domain
:feature:search:impl -.-> :core:ui
:feature:search:impl -.-> :feature:interests:api
:feature:search:impl -.-> :feature:search:api
:feature:search:impl -.-> :feature:topic:api
:feature:settings:impl -.-> :core:data
:feature:settings:impl -.-> :core:designsystem
:feature:settings:impl -.-> :core:ui
:feature:topic:api -.-> :core:designsystem
:feature:topic:api --> :core:navigation
:feature:topic:api -.-> :core:ui
:feature:topic:impl -.-> :core:data
:feature:topic:impl -.-> :core:designsystem
:feature:topic:impl -.-> :core:ui
:feature:topic:impl -.-> :feature:topic:api
:sync:work -.-> :core:analytics
:sync:work -.-> :core:data
:sync:work -.-> :core:notifications
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -45,8 +45,8 @@ android {
)
}
testOptions.managedDevices.devices {
create<com.android.build.api.dsl.ManagedVirtualDevice>("pixel6Api33") {
testOptions.managedDevices.localDevices {
create("pixel6Api33") {
device = "Pixel 6"
apiLevel = 33
systemImageSource = "aosp"

@ -37,7 +37,7 @@ kotlin {
}
dependencies {
compileOnly(libs.android.gradlePlugin)
compileOnly(libs.android.gradleApiPlugin)
compileOnly(libs.android.tools.common)
compileOnly(libs.compose.gradlePlugin)
compileOnly(libs.firebase.crashlytics.gradlePlugin)
@ -78,9 +78,13 @@ gradlePlugin {
id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId
implementationClass = "AndroidLibraryConventionPlugin"
}
register("androidFeature") {
id = libs.plugins.nowinandroid.android.feature.get().pluginId
implementationClass = "AndroidFeatureConventionPlugin"
register("androidFeatureImpl") {
id = libs.plugins.nowinandroid.android.feature.impl.get().pluginId
implementationClass = "AndroidFeatureImplConventionPlugin"
}
register("androidFeatureApi") {
id = libs.plugins.nowinandroid.android.feature.api.get().pluginId
implementationClass = "AndroidFeatureApiConventionPlugin"
}
register("androidLibraryJacoco") {
id = libs.plugins.nowinandroid.android.library.jacoco.get().pluginId
@ -114,5 +118,9 @@ gradlePlugin {
id = libs.plugins.nowinandroid.jvm.library.get().pluginId
implementationClass = "JvmLibraryConventionPlugin"
}
register("root") {
id = libs.plugins.nowinandroid.root.get().pluginId
implementationClass = "RootPlugin"
}
}
}

@ -31,5 +31,4 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
configureAndroidCompose(extension)
}
}
}

@ -16,38 +16,35 @@
import com.android.build.api.dsl.ApplicationExtension
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.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.getByType
class AndroidApplicationConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
apply("nowinandroid.android.lint")
apply("com.dropbox.dependency-guard")
}
apply(plugin = "com.android.application")
apply(plugin = "org.jetbrains.kotlin.android")
apply(plugin = "nowinandroid.android.lint")
apply(plugin = "com.dropbox.dependency-guard")
extensions.configure<ApplicationExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 35
defaultConfig.targetSdk = 36
@Suppress("UnstableApiUsage")
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
}
extensions.configure<ApplicationAndroidComponentsExtension> {
configurePrintApksTask(this)
configureBadgingTasks(extensions.getByType<BaseExtension>(), this)
configureBadgingTasks(extensions.getByType<ApplicationExtension>(), this)
}
}
}
}

@ -19,6 +19,7 @@ import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.exclude
@ -26,11 +27,9 @@ import org.gradle.kotlin.dsl.exclude
class AndroidApplicationFirebaseConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.gms.google-services")
apply("com.google.firebase.firebase-perf")
apply("com.google.firebase.crashlytics")
}
apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.firebase-perf")
apply(plugin = "com.google.firebase.crashlytics")
dependencies {
val bom = libs.findLibrary("firebase-bom").get()

@ -19,12 +19,14 @@ import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureJacoco
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
class AndroidApplicationJacocoConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("jacoco")
apply(plugin = "jacoco")
val androidExtension = extensions.getByType<ApplicationExtension>()
androidExtension.buildTypes.configureEach {

@ -0,0 +1,34 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureApiConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
apply(plugin = "nowinandroid.android.library")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
dependencies {
"api"(project(":core:navigation"))
}
}
}
}

@ -14,22 +14,21 @@
* limitations under the License.
*/
import com.android.build.gradle.LibraryExtension
import com.android.build.api.dsl.LibraryExtension
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidFeatureConventionPlugin : Plugin<Project> {
class AndroidFeatureImplConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply {
apply("nowinandroid.android.library")
apply("nowinandroid.hilt")
apply("org.jetbrains.kotlin.plugin.serialization")
}
apply(plugin = "nowinandroid.android.library")
apply(plugin = "nowinandroid.hilt")
extensions.configure<LibraryExtension> {
testOptions.animationsDisabled = true
configureGradleManagedDevices(this)
@ -39,15 +38,15 @@ class AndroidFeatureConventionPlugin : Plugin<Project> {
"implementation"(project(":core:ui"))
"implementation"(project(":core:designsystem"))
"implementation"(libs.findLibrary("androidx.hilt.navigation.compose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get())
"implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.navigation.compose").get())
"implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModelCompose").get())
"implementation"(libs.findLibrary("androidx.navigation3.runtime").get())
"implementation"(libs.findLibrary("androidx.tracing.ktx").get())
"implementation"(libs.findLibrary("kotlinx.serialization.json").get())
"testImplementation"(libs.findLibrary("androidx.navigation.testing").get())
"androidTestImplementation"(libs.findLibrary("androidx.lifecycle.runtimeTesting").get())
"androidTestImplementation"(
libs.findLibrary("androidx.lifecycle.runtimeTesting").get(),
)
}
}
}

@ -14,7 +14,7 @@
* limitations under the License.
*/
import com.android.build.gradle.LibraryExtension
import com.android.build.api.dsl.LibraryExtension
import com.google.samples.apps.nowinandroid.configureAndroidCompose
import org.gradle.api.Plugin
import org.gradle.api.Project
@ -31,5 +31,4 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
configureAndroidCompose(extension)
}
}
}

@ -14,8 +14,8 @@
* limitations under the License.
*/
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.android.build.gradle.LibraryExtension
import com.google.samples.apps.nowinandroid.configureFlavors
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
@ -24,28 +24,31 @@ import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
class AndroidLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("nowinandroid.android.lint")
}
apply(plugin = "com.android.library")
apply(plugin = "org.jetbrains.kotlin.android")
apply(plugin = "nowinandroid.android.lint")
extensions.configure<LibraryExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 35
testOptions.targetSdk = 36
lint.targetSdk = 36
defaultConfig.targetSdk = 36
defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testOptions.animationsDisabled = true
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() + "_"
resourcePrefix =
path.split("""\W""".toRegex()).drop(1).distinct().joinToString(separator = "_")
.lowercase() + "_"
}
extensions.configure<LibraryAndroidComponentsExtension> {
configurePrintApksTask(this)

@ -15,17 +15,18 @@
*/
import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.api.variant.LibraryAndroidComponentsExtension
import com.google.samples.apps.nowinandroid.configureJacoco
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.getByType
class AndroidLibraryJacocoConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("jacoco")
apply(plugin = "jacoco")
val androidExtension = extensions.getByType<LibraryExtension>()
androidExtension.buildTypes.configureEach {

@ -19,6 +19,7 @@ import com.android.build.api.dsl.LibraryExtension
import com.android.build.api.dsl.Lint
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
class AndroidLintConventionPlugin : Plugin<Project> {
@ -32,7 +33,7 @@ class AndroidLintConventionPlugin : Plugin<Project> {
configure<LibraryExtension> { lint(Lint::configure) }
else -> {
pluginManager.apply("com.android.lint")
apply(plugin = "com.android.lint")
configure<Lint>(Lint::configure)
}
}

@ -19,6 +19,7 @@ import com.google.devtools.ksp.gradle.KspExtension
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
@ -26,8 +27,8 @@ class AndroidRoomConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("androidx.room")
pluginManager.apply("com.google.devtools.ksp")
apply(plugin = "androidx.room")
apply(plugin = "com.google.devtools.ksp")
extensions.configure<KspExtension> {
arg("room.generateKotlin", "true")

@ -14,27 +14,25 @@
* limitations under the License.
*/
import com.android.build.gradle.TestExtension
import com.android.build.api.dsl.TestExtension
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
class AndroidTestConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.test")
apply("org.jetbrains.kotlin.android")
}
apply(plugin = "com.android.test")
apply(plugin = "org.jetbrains.kotlin.android")
extensions.configure<TestExtension> {
configureKotlinAndroid(this)
defaultConfig.targetSdk = 35
defaultConfig.targetSdk = 36
configureGradleManagedDevices(this)
}
}
}
}

@ -18,12 +18,14 @@ import com.android.build.gradle.api.AndroidBasePlugin
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class HiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.google.devtools.ksp")
apply(plugin = "com.google.devtools.ksp")
dependencies {
"ksp"(libs.findLibrary("hilt.compiler").get())
}
@ -37,7 +39,7 @@ class HiltConventionPlugin : Plugin<Project> {
/** Add support for Android modules, based on [AndroidBasePlugin] */
pluginManager.withPlugin("com.android.base") {
pluginManager.apply("dagger.hilt.android.plugin")
apply(plugin = "dagger.hilt.android.plugin")
dependencies {
"implementation"(libs.findLibrary("hilt.android").get())
}

@ -18,15 +18,15 @@ import com.google.samples.apps.nowinandroid.configureKotlinJvm
import com.google.samples.apps.nowinandroid.libs
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.dependencies
class JvmLibraryConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.jvm")
apply("nowinandroid.android.lint")
}
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "nowinandroid.android.lint")
configureKotlinJvm()
dependencies {
"testImplementation"(libs.findLibrary("kotlin.test").get())

@ -0,0 +1,26 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.samples.apps.nowinandroid.configureGraphTasks
import org.gradle.api.Plugin
import org.gradle.api.Project
class RootPlugin : Plugin<Project> {
override fun apply(target: Project) {
require(target.path == ":")
target.subprojects { configureGraphTasks() }
}
}

@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
@ -42,13 +41,6 @@ internal fun Project.configureAndroidCompose(
"implementation"(libs.findLibrary("androidx-compose-ui-tooling-preview").get())
"debugImplementation"(libs.findLibrary("androidx-compose-ui-tooling").get())
}
testOptions {
unitTests {
// For Robolectric
isIncludeAndroidResources = true
}
}
}
extensions.configure<ComposeCompilerGradlePluginExtension> {
@ -67,7 +59,7 @@ internal fun Project.configureAndroidCompose(
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
stabilityConfigurationFile =
isolated.rootProject.projectDirectory.file("compose_compiler_config.conf")
stabilityConfigurationFiles
.add(isolated.rootProject.projectDirectory.file("compose_compiler_config.conf"))
}
}

@ -18,8 +18,8 @@ package com.google.samples.apps.nowinandroid
import com.android.SdkConstants
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.android.build.gradle.BaseExtension
import com.google.common.truth.Truth.assertWithMessage
import org.gradle.api.DefaultTask
import org.gradle.api.Project
@ -110,7 +110,7 @@ private fun String.capitalized() = replaceFirstChar {
}
fun Project.configureBadgingTasks(
baseExtension: BaseExtension,
baseExtension: ApplicationExtension,
componentsExtension: ApplicationAndroidComponentsExtension,
) {
// Registers a callback to be called, when a new variant is configured

@ -0,0 +1,353 @@
/*
* Copyright 2025 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.utils.associateWithNotNull
import com.google.samples.apps.nowinandroid.PluginType.Unknown
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.ProjectDependency
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity.NONE
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.register
import org.gradle.kotlin.dsl.withType
import kotlin.text.RegexOption.DOT_MATCHES_ALL
/**
* Generates module dependency graphs with `graphDump` task, and update the corresponding `README.md` file with `graphUpdate`.
*
* This is not an optimal implementation and could be improved if needed:
* - [Graph.invoke] is **recursively** searching through dependent projects (although in practice it will never reach a stack overflow).
* - [Graph.invoke] is entirely re-executed for all projects, without re-using intermediate values.
* - [Graph.invoke] is always executed during Gradle's Configuration phase (but takes in general less than 1 ms for a project).
*
* The resulting graphs can be configured with `graph.ignoredProjects` and `graph.supportedConfigurations` properties.
*/
private class Graph(
private val root: Project,
private val dependencies: MutableMap<Project, Set<Pair<Configuration, Project>>> = mutableMapOf(),
private val plugins: MutableMap<Project, PluginType> = mutableMapOf(),
private val seen: MutableSet<String> = mutableSetOf(),
) {
private val ignoredProjects = root.providers.gradleProperty("graph.ignoredProjects")
.map { it.split(",").toSet() }
.orElse(emptySet())
private val supportedConfigurations =
root.providers.gradleProperty("graph.supportedConfigurations")
.map { it.split(",").toSet() }
.orElse(setOf("api", "implementation", "baselineProfile", "testedApks"))
operator fun invoke(project: Project = root): Graph {
if (project.path in seen) return this
seen += project.path
plugins.putIfAbsent(
project,
PluginType.entries.firstOrNull { project.pluginManager.hasPlugin(it.id) } ?: Unknown,
)
dependencies.compute(project) { _, u -> u.orEmpty() }
project.configurations
.matching { it.name in supportedConfigurations.get() }
.associateWithNotNull { it.dependencies.withType<ProjectDependency>().ifEmpty { null } }
.flatMap { (c, value) -> value.map { dep -> c to project.project(dep.path) } }
.filter { (_, p) -> p.path !in ignoredProjects.get() }
.forEach { (configuration: Configuration, projectDependency: Project) ->
dependencies.compute(project) { _, u -> u.orEmpty() + (configuration to projectDependency) }
invoke(projectDependency)
}
return this
}
fun dependencies(): Map<String, Set<Pair<String, String>>> = dependencies
.mapKeys { it.key.path }
.mapValues { it.value.mapTo(mutableSetOf()) { (c, p) -> c.name to p.path } }
fun plugins() = plugins.mapKeys { it.key.path }
}
/**
* Declaration order is important, as only the first match will be retained.
*/
internal enum class PluginType(val id: String, val ref: String, val style: String) {
AndroidApplication(
id = "nowinandroid.android.application",
ref = "android-application",
style = "fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000",
),
AndroidFeature(
id = "nowinandroid.android.feature",
ref = "android-feature",
style = "fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000",
),
AndroidLibrary(
id = "nowinandroid.android.library",
ref = "android-library",
style = "fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000",
),
AndroidTest(
id = "nowinandroid.android.test",
ref = "android-test",
style = "fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000",
),
Jvm(
id = "nowinandroid.jvm.library",
ref = "jvm-library",
style = "fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000",
),
Unknown(
id = "?",
ref = "unknown",
style = "fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000",
),
}
internal fun Project.configureGraphTasks() {
if (!buildFile.exists()) return // Ignore root modules without build file
val dumpTask = tasks.register<GraphDumpTask>("graphDump") {
val graph = Graph(this@configureGraphTasks).invoke()
projectPath = this@configureGraphTasks.path
dependencies = graph.dependencies()
plugins = graph.plugins()
output = this@configureGraphTasks.layout.buildDirectory.file("mermaid/graph.txt")
legend = this@configureGraphTasks.layout.buildDirectory.file("mermaid/legend.txt")
}
tasks.register<GraphUpdateTask>("graphUpdate") {
projectPath = this@configureGraphTasks.path
input = dumpTask.flatMap { it.output }
legend = dumpTask.flatMap { it.legend }
output = this@configureGraphTasks.layout.projectDirectory.file("README.md")
}
}
@CacheableTask
private abstract class GraphDumpTask : DefaultTask() {
@get:Input
abstract val projectPath: Property<String>
@get:Input
abstract val dependencies: MapProperty<String, Set<Pair<String, String>>>
@get:Input
abstract val plugins: MapProperty<String, PluginType>
@get:OutputFile
abstract val output: RegularFileProperty
@get:OutputFile
abstract val legend: RegularFileProperty
override fun getDescription() = "Dumps project dependencies to a mermaid file."
@TaskAction
operator fun invoke() {
output.get().asFile.writeText(mermaid())
legend.get().asFile.writeText(legend())
logger.lifecycle(output.get().asFile.toPath().toUri().toString())
}
private fun mermaid() = buildString {
val dependencies: Set<Dependency> = dependencies.get()
.flatMapTo(mutableSetOf()) { (project, entries) -> entries.map { it.toDependency(project) } }
// FrontMatter configuration (not supported yet on GitHub.com)
appendLine(
// language=YAML
"""
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
""".trimIndent(),
)
// Graph declaration
appendLine("graph TB")
// Nodes and subgraphs
val (rootProjects, nestedProjects) = dependencies
.map { listOf(it.project, it.dependency) }.flatten().toSet()
.plus(projectPath.get()) // Special case when this specific module has no other dependency
.groupBy { it.substringBeforeLast(":") }
.entries.partition { it.key.isEmpty() }
val orderedGroups = nestedProjects.groupBy {
if (it.key.count { char -> char == ':' } > 1) it.key.substringBeforeLast(":") else ""
}
orderedGroups.forEach { (outerGroup, innerGroups) ->
if (outerGroup.isNotEmpty()) {
appendLine(" subgraph $outerGroup")
appendLine(" direction TB")
}
innerGroups.sortedWith(
compareBy(
{ (group, _) ->
dependencies.filter { dep ->
val toGroup = dep.dependency.substringBeforeLast(":")
toGroup == group && dep.project.substringBeforeLast(":") != group
}.count()
},
{ -it.value.size },
),
).forEach { (group, projects) ->
val indent = if (outerGroup.isNotEmpty()) 4 else 2
appendLine(" ".repeat(indent) + "subgraph $group")
appendLine(" ".repeat(indent) + " direction TB")
projects.sorted().forEach {
appendLine(it.alias(indent = indent + 2, plugins.get().getValue(it)))
}
appendLine(" ".repeat(indent) + "end")
}
if (outerGroup.isNotEmpty()) {
appendLine(" end")
}
}
rootProjects.flatMap { it.value }.sortedDescending().forEach {
appendLine(it.alias(indent = 2, plugins.get().getValue(it)))
}
// Links
if (dependencies.isNotEmpty()) appendLine()
dependencies
.sortedWith(compareBy({ it.project }, { it.dependency }, { it.configuration }))
.forEach { appendLine(it.link(indent = 2)) }
// Classes
appendLine()
PluginType.entries.forEach { appendLine(it.classDef()) }
}
private fun legend() = buildString {
appendLine("graph TB")
listOf(
"application" to PluginType.AndroidApplication,
"feature" to PluginType.AndroidFeature,
"library" to PluginType.AndroidLibrary,
"jvm" to PluginType.Jvm,
).forEach { (name, type) ->
appendLine(name.alias(indent = 2, type))
}
appendLine()
listOf(
Dependency("application", "implementation", "feature"),
Dependency("library", "api", "jvm"),
).forEach {
appendLine(it.link(indent = 2))
}
appendLine()
PluginType.entries.forEach { appendLine(it.classDef()) }
}
private class Dependency(val project: String, val configuration: String, val dependency: String)
private fun Pair<String, String>.toDependency(project: String) =
Dependency(project, configuration = first, dependency = second)
private fun String.alias(indent: Int, pluginType: PluginType): String = buildString {
append(" ".repeat(indent))
append(this@alias)
append("[").append(substringAfterLast(":")).append("]:::")
append(pluginType.ref)
}
private fun Dependency.link(indent: Int) = buildString {
append(" ".repeat(indent))
append(project).append(" ")
append(
when (configuration) {
"api" -> "-->"
"implementation" -> "-.->"
else -> "-.->|$configuration|"
},
)
append(" ").append(dependency)
}
private fun PluginType.classDef() = "classDef $ref $style;"
}
@CacheableTask
private abstract class GraphUpdateTask : DefaultTask() {
@get:Input
abstract val projectPath: Property<String>
@get:InputFile
@get:PathSensitive(NONE)
abstract val input: RegularFileProperty
@get:InputFile
@get:PathSensitive(NONE)
abstract val legend: RegularFileProperty
@get:OutputFile
abstract val output: RegularFileProperty
override fun getDescription() = "Updates Markdown file with the corresponding dependency graph."
@TaskAction
operator fun invoke() = with(output.get().asFile) {
if (!exists()) {
createNewFile()
writeText(
"""
# `${projectPath.get()}`
## Module dependency graph
<!--region graph--> <!--endregion-->
""".trimIndent(),
)
}
val mermaid = input.get().asFile.readText().trimTrailingNewLines()
val legend = legend.get().asFile.readText().trimTrailingNewLines()
val regex = """(<!--region graph-->)(.*?)(<!--endregion-->)""".toRegex(DOT_MATCHES_ALL)
val text = readText().replace(regex) { match ->
val (start, _, end) = match.destructured
"""
|$start
|```mermaid
|$mermaid
|```
|
|<details><summary>📋 Graph legend</summary>
|
|```mermaid
|$legend
|```
|
|</details>
|$end
""".trimMargin()
}
writeText(text)
}
private fun String.trimTrailingNewLines() = lines()
.dropLastWhile(String::isBlank)
.joinToString(System.lineSeparator())
}

@ -23,7 +23,6 @@ import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.kotlin.dsl.assign
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.provideDelegate
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinBaseExtension
@ -36,10 +35,10 @@ internal fun Project.configureKotlinAndroid(
commonExtension: CommonExtension<*, *, *, *, *, *>,
) {
commonExtension.apply {
compileSdk = 35
compileSdk = 36
defaultConfig {
minSdk = 21
minSdk = 23
}
compileOptions {
@ -78,14 +77,16 @@ internal fun Project.configureKotlinJvm() {
private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() = configure<T> {
// Treat all Kotlin warnings as errors (disabled by default)
// Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties
val warningsAsErrors: String? by project
val warningsAsErrors = providers.gradleProperty("warningsAsErrors").map {
it.toBoolean()
}.orElse(false)
when (this) {
is KotlinAndroidProjectExtension -> compilerOptions
is KotlinJvmProjectExtension -> compilerOptions
else -> TODO("Unsupported project extension $this ${T::class}")
}.apply {
jvmTarget = JvmTarget.JVM_11
allWarningsAsErrors = warningsAsErrors.toBoolean()
allWarningsAsErrors = warningsAsErrors
freeCompilerArgs.add(
// Enable experimental coroutines APIs, including Flow
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",

@ -2,3 +2,5 @@
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true
org.gradle.configuration-cache=true
org.gradle.configuration-cache.parallel=true

@ -14,28 +14,6 @@
* limitations under the License.
*/
buildscript {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
// Android Build Server
maven { url = uri("../nowinandroid-prebuilts/m2repository") }
}
dependencies {
classpath(libs.google.oss.licenses.plugin) {
exclude(group = "com.google.protobuf")
}
}
}
/*
* By listing all the plugins used throughout all subprojects in the root project build script, it
* ensures that the build script classpath remains the same for all projects. This avoids potential
@ -58,7 +36,7 @@ plugins {
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.roborazzi) apply false
alias(libs.plugins.secrets) apply false
alias(libs.plugins.google.osslicenses) apply false
alias(libs.plugins.room) apply false
alias(libs.plugins.module.graph) apply true // Plugin applied to allow module graph generation
alias(libs.plugins.nowinandroid.root)
}

@ -1,3 +1,48 @@
# :core:analytics module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_analytics.svg)
# `:core:analytics`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:analytics[analytics]:::android-library
end
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -1,3 +1,48 @@
# :core:common module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_common.svg)
# `:core:common`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:common[common]:::jvm-library
end
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -1,3 +1,73 @@
# :core:data-test module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_data_test.svg)
# `:core:data-test`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:analytics[analytics]:::android-library
:core:common[common]:::jvm-library
:core:data[data]:::android-library
:core:data-test[data-test]:::android-library
:core:database[database]:::android-library
:core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::android-library
:core:model[model]:::jvm-library
:core:network[network]:::android-library
:core:notifications[notifications]:::android-library
end
:core:data -.-> :core:analytics
:core:data --> :core:common
:core:data --> :core:database
:core:data --> :core:datastore
:core:data --> :core:network
:core:data -.-> :core:notifications
:core:data-test --> :core:data
:core:database --> :core:model
:core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto
:core:datastore --> :core:model
:core:network --> :core:common
:core:network --> :core:model
:core:notifications -.-> :core:common
:core:notifications --> :core:model
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -1,3 +1,71 @@
# :core:data module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_data.svg)
# `:core:data`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:analytics[analytics]:::android-library
:core:common[common]:::jvm-library
:core:data[data]:::android-library
:core:database[database]:::android-library
:core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::android-library
:core:model[model]:::jvm-library
:core:network[network]:::android-library
:core:notifications[notifications]:::android-library
end
:core:data -.-> :core:analytics
:core:data --> :core:common
:core:data --> :core:database
:core:data --> :core:datastore
:core:data --> :core:network
:core:data -.-> :core:notifications
:core:database --> :core:model
:core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto
:core:datastore --> :core:model
:core:network --> :core:common
:core:network --> :core:model
:core:notifications -.-> :core:common
:core:notifications --> :core:model
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -22,11 +22,7 @@ plugins {
android {
namespace = "com.google.samples.apps.nowinandroid.core.data"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
testOptions.unitTests.isIncludeAndroidResources = true
}
dependencies {

@ -24,9 +24,9 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.datetime.Clock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class UserNewsResourceTest {

@ -89,6 +89,7 @@ class OfflineFirstNewsRepositoryTest {
@Test
fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() =
testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
newsResourceDao.getNewsResources()
.first()

@ -67,6 +67,8 @@ class OfflineFirstTopicsRepositoryTest {
@Test
fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() =
testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
topicDao.getTopicEntities()
.first()

@ -36,7 +36,7 @@ class TestNewsResourceDao : NewsResourceDao {
private val entitiesStateFlow = MutableStateFlow(emptyList<NewsResourceEntity>())
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = listOf()
internal var topicCrossReferences: List<NewsResourceTopicCrossRef> = emptyList()
override fun getNewsResources(
useFilterTopicIds: Boolean,

@ -1,3 +1,51 @@
# :core:database module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_database.svg)
# `:core:database`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:database[database]:::android-library
:core:model[model]:::jvm-library
end
:core:database --> :core:model
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -0,0 +1,47 @@
/*
* Copyright 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.database.dao
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import org.junit.After
import org.junit.Before
internal abstract class DatabaseTest {
private lateinit var db: NiaDatabase
protected lateinit var newsResourceDao: NewsResourceDao
protected lateinit var topicDao: TopicDao
@Before
fun setup() {
db = run {
val context = ApplicationProvider.getApplicationContext<Context>()
Room.inMemoryDatabaseBuilder(
context,
NiaDatabase::class.java,
).build()
}
newsResourceDao = db.newsResourceDao()
topicDao = db.topicDao()
}
@After
fun teardown() = db.close()
}

@ -16,10 +16,6 @@
package com.google.samples.apps.nowinandroid.core.database.dao
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.google.samples.apps.nowinandroid.core.database.NiaDatabase
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
@ -27,33 +23,13 @@ import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceDaoTest {
private lateinit var newsResourceDao: NewsResourceDao
private lateinit var topicDao: TopicDao
private lateinit var db: NiaDatabase
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context,
NiaDatabase::class.java,
).build()
newsResourceDao = db.newsResourceDao()
topicDao = db.topicDao()
}
@After
fun closeDb() = db.close()
internal class NewsResourceDaoTest : DatabaseTest() {
@Test
fun newsResourceDao_fetches_items_by_descending_publish_date() = runTest {
fun getNewsResources_allEntries_areOrderedByPublishDateDesc() = runTest {
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
@ -88,7 +64,7 @@ class NewsResourceDaoTest {
}
@Test
fun newsResourceDao_filters_items_by_news_ids_by_descending_publish_date() = runTest {
fun getNewsResources_filteredById_areOrderedByDescendingPublishDate() = runTest {
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
@ -126,7 +102,7 @@ class NewsResourceDaoTest {
}
@Test
fun newsResourceDao_filters_items_by_topic_ids_by_descending_publish_date() = runTest {
fun getNewsResources_filteredByTopicId_areOrderedByDescendingPublishDate() = runTest {
val topicEntities = listOf(
testTopicEntity(
id = "1",
@ -186,7 +162,7 @@ class NewsResourceDaoTest {
}
@Test
fun newsResourceDao_filters_items_by_news_and_topic_ids_by_descending_publish_date() = runTest {
fun getNewsResources_filteredByIdAndTopicId_areOrderedByDescendingPublishDate() = runTest {
val topicEntities = listOf(
testTopicEntity(
id = "1",
@ -248,7 +224,7 @@ class NewsResourceDaoTest {
}
@Test
fun newsResourceDao_deletes_items_by_ids() =
fun deleteNewsResources_byId() =
runTest {
val newsResourceEntities = listOf(
testNewsResource(

@ -0,0 +1,126 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.database.dao
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
internal class TopicDaoTest : DatabaseTest() {
@Test
fun getTopics() = runTest {
insertTopics()
val savedTopics = topicDao.getTopicEntities().first()
assertEquals(
listOf("1", "2", "3"),
savedTopics.map { it.id },
)
}
@Test
fun getTopic() = runTest {
insertTopics()
val savedTopicEntity = topicDao.getTopicEntity("2").first()
assertEquals("performance", savedTopicEntity.name)
}
@Test
fun getTopics_oneOff() = runTest {
insertTopics()
val savedTopics = topicDao.getOneOffTopicEntities()
assertEquals(
listOf("1", "2", "3"),
savedTopics.map { it.id },
)
}
@Test
fun getTopics_byId() = runTest {
insertTopics()
val savedTopics = topicDao.getTopicEntities(setOf("1", "2"))
.first()
assertEquals(listOf("compose", "performance"), savedTopics.map { it.name })
}
@Test
fun insertTopic_newEntryIsIgnoredIfAlreadyExists() = runTest {
insertTopics()
topicDao.insertOrIgnoreTopics(
listOf(testTopicEntity("1", "compose")),
)
val savedTopics = topicDao.getOneOffTopicEntities()
assertEquals(3, savedTopics.size)
}
@Test
fun upsertTopic_existingEntryIsUpdated() = runTest {
insertTopics()
topicDao.upsertTopics(
listOf(testTopicEntity("1", "newName")),
)
val savedTopics = topicDao.getOneOffTopicEntities()
assertEquals(3, savedTopics.size)
assertEquals("newName", savedTopics.first().name)
}
@Test
fun deleteTopics_byId_existingEntriesAreDeleted() = runTest {
insertTopics()
topicDao.deleteTopics(listOf("1", "2"))
val savedTopics = topicDao.getOneOffTopicEntities()
assertEquals(1, savedTopics.size)
assertEquals("3", savedTopics.first().id)
}
private suspend fun insertTopics() {
val topicEntities = listOf(
testTopicEntity("1", "compose"),
testTopicEntity("2", "performance"),
testTopicEntity("3", "headline"),
)
topicDao.insertOrIgnoreTopics(topicEntities)
}
}
private fun testTopicEntity(
id: String = "0",
name: String,
) = TopicEntity(
id = id,
name = name,
shortDescription = "",
longDescription = "",
url = "",
imageUrl = "",
)

@ -49,5 +49,5 @@ fun NewsResourceEntity.asExternalModel() = NewsResource(
headerImageUrl = headerImageUrl,
publishDate = publishDate,
type = type,
topics = listOf(),
topics = emptyList(),
)

@ -1,3 +1,48 @@
# :core:datastore-proto module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore_proto.svg)
# `:core:datastore-proto`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:datastore-proto[datastore-proto]:::android-library
end
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -1,3 +1,58 @@
# :core:datastore-test module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore_test.svg)
# `:core:datastore-test`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:common[common]:::jvm-library
:core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::android-library
:core:datastore-test[datastore-test]:::android-library
:core:model[model]:::jvm-library
end
:core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto
:core:datastore --> :core:model
:core:datastore-test -.-> :core:common
:core:datastore-test -.-> :core:datastore
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -1,3 +1,55 @@
# :core:datastore module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_datastore.svg)
# `:core:datastore`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:common[common]:::jvm-library
:core:datastore[datastore]:::android-library
:core:datastore-proto[datastore-proto]:::android-library
:core:model[model]:::jvm-library
end
:core:datastore -.-> :core:common
:core:datastore --> :core:datastore-proto
:core:datastore --> :core:model
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -1,3 +1,48 @@
# :core:designsystem module
## Dependency graph
![Dependency graph](../../docs/images/graphs/dep_graph_core_designsystem.svg)
# `:core:designsystem`
## Module dependency graph
<!--region graph-->
```mermaid
---
config:
layout: elk
elk:
nodePlacementStrategy: SIMPLE
---
graph TB
subgraph :core
direction TB
:core:designsystem[designsystem]:::android-library
end
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<details><summary>📋 Graph legend</summary>
```mermaid
graph TB
application[application]:::android-application
feature[feature]:::android-feature
library[library]:::android-library
jvm[jvm]:::jvm-library
application -.-> feature
library --> jvm
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
</details>
<!--endregion-->

@ -22,6 +22,7 @@ plugins {
android {
namespace = "com.google.samples.apps.nowinandroid.core.designsystem"
testOptions.unitTests.isIncludeAndroidResources = true
}
dependencies {

@ -45,7 +45,7 @@ fun NiaTopAppBar(
actionIcon: ImageVector,
actionIconContentDescription: String,
modifier: Modifier = Modifier,
colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(),
onNavigationClick: () -> Unit = {},
onActionClick: () -> Unit = {},
) {

@ -1,5 +1,5 @@
/*
* Copyright 2022 The Android Open Source Project
* Copyright 2025 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.
@ -22,6 +22,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.style.LineHeightStyle.Alignment
import androidx.compose.ui.text.style.LineHeightStyle.Trim
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.sp
/**
@ -33,30 +35,40 @@ internal val NiaTypography = Typography(
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
displayMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
displaySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
headlineLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
headlineSmall = TextStyle(
fontWeight = FontWeight.Normal,
@ -67,6 +79,8 @@ internal val NiaTypography = Typography(
alignment = Alignment.Bottom,
trim = Trim.None,
),
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Bold,
@ -77,18 +91,24 @@ internal val NiaTypography = Typography(
alignment = Alignment.Bottom,
trim = Trim.LastLineBottom,
),
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = 0.1.sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
// Default text style
bodyLarge = TextStyle(
@ -100,18 +120,24 @@ internal val NiaTypography = Typography(
alignment = Alignment.Center,
trim = Trim.None,
),
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
// Used for Button
labelLarge = TextStyle(
@ -123,6 +149,8 @@ internal val NiaTypography = Typography(
alignment = Alignment.Center,
trim = Trim.LastLineBottom,
),
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
// Used for Navigation items
labelMedium = TextStyle(
@ -134,6 +162,8 @@ internal val NiaTypography = Typography(
alignment = Alignment.Center,
trim = Trim.LastLineBottom,
),
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
// Used for Tag
labelSmall = TextStyle(
@ -145,5 +175,7 @@ internal val NiaTypography = Typography(
alignment = Alignment.Center,
trim = Trim.LastLineBottom,
),
textDirection = TextDirection.Ltr,
textAlign = TextAlign.Left,
),
)

@ -0,0 +1,17 @@
#
# Copyright 2025 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.
#
sdk = 35

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 530 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 703 B

After

Width:  |  Height:  |  Size: 873 B

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

Loading…
Cancel
Save